Skip to content

Miner auth ban #96

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Feb 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 16 additions & 9 deletions purchase_focus_video.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,17 +58,18 @@
Remember to keep your wallet information secure and never share your private keys.
"""

import os
import requests
import bittensor as bt
from bittensor import wallet as btcli_wallet
import argparse
import time
import json
from tabulate import tabulate
from datetime import datetime
import multiprocessing
import os
import sys
import time
from datetime import datetime

import bittensor as bt
import requests
from bittensor import wallet as btcli_wallet
from tabulate import tabulate

parser = argparse.ArgumentParser(description='Interact with the OMEGA Focus Videos API.')
args = parser.parse_args()
Expand All @@ -80,6 +81,7 @@
if SUBTENSOR_NETWORK == "test" else
"https://sn24-api.omegatron.ai"
)
# API_BASE = "http://localhost:8000"

CYAN = "\033[96m"
GREEN = "\033[92m"
Expand Down Expand Up @@ -213,7 +215,12 @@ def get_auth_headers(wallet):
miner_hotkey_signature = f"0x{hotkey.sign(miner_hotkey).hex()}"
return miner_hotkey, miner_hotkey_signature

def purchase_video(video_id=None, wallet_name=None, wallet_hotkey=None, wallet_path=None):
def purchase_video(
video_id=None,
wallet_name=None,
wallet_hotkey=None,
wallet_path=None
):
if not video_id:
video_id = input(f"{CYAN}Enter focus video id: {RESET}")

Expand All @@ -225,7 +232,7 @@ def purchase_video(video_id=None, wallet_name=None, wallet_hotkey=None, wallet_p
purchase_response = requests.post(
API_BASE + "/api/focus/purchase",
auth=(miner_hotkey, miner_hotkey_signature),
json={"video_id": video_id, "miner_hotkey": miner_hotkey},
json={"video_id": video_id},
headers={"Content-Type": "application/json"},
timeout=60
)
Expand Down
43 changes: 26 additions & 17 deletions validator-api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
from validator_api.utils.marketplace import (TASK_TYPE_MAP,
get_max_focus_alpha_per_day,
get_purchase_max_focus_alpha)
from validator_api.database.models.miner_bans import miner_banned_until

from omega.protocol import AudioMetadata, VideoMetadata

Expand Down Expand Up @@ -203,15 +204,18 @@ class VideoPurchaseRevert(BaseModel):


def get_hotkey(credentials: Annotated[HTTPBasicCredentials, Depends(security)]) -> str:
keypair = Keypair(ss58_address=credentials.username)

if keypair.verify(credentials.username, credentials.password):
return credentials.username

raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Signature mismatch",
)
# print(f"Username: {credentials.username}, Password: {credentials.password}")
try:
keypair = Keypair(ss58_address=credentials.username)
# print(f"Keypair: {keypair}")
if keypair.verify(credentials.username, credentials.password):
return credentials.username
except Exception as e:
print(f"Error verifying keypair: {e}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Error verifying keypair: {e}, make sure Basic Auth username is your hotkey SS58 address and the password is your hotkey's signature hex string (not private key!)."
)


def check_commune_validator_hotkey(hotkey: str, modules_keys):
Expand Down Expand Up @@ -673,24 +677,29 @@ async def _get_available_focus_video_list(
"""
return await get_all_available_focus(db)

# FV TODO: let's do proper miner auth here instead, and then from the retrieved hotkey, we can also
# retrieve the coldkey and use that to confirm the transfer
@app.post("/api/focus/purchase")
@limiter.limit("2/minute")
async def purchase_video(
request: Request,
background_tasks: BackgroundTasks,
video_id: Annotated[str, Body()],
miner_hotkey: Annotated[str, Body()],
video_id: Annotated[str, Body(embed=True)],
hotkey: Annotated[str, Depends(get_hotkey)],
db: Session = Depends(get_db),
):
banned_until = miner_banned_until(db, hotkey)
if banned_until:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Miner is banned from purchasing focus videos until {banned_until} due to too many failed purchases in a row. Contact a team member if you believe this is an error.",
)

if await already_purchased_max_focus_tao(db):
print("Purchases in the last 24 hours have reached the max focus tao limit.")
raise HTTPException(
400, "Purchases in the last 24 hours have reached the max focus tao limit, please try again later.")

# run with_lock True
availability = await check_availability(db, video_id, miner_hotkey, True)
availability = await check_availability(db, video_id, hotkey, True)
print('availability', availability)
if availability['status'] == 'success':
amount = availability['price']
Expand All @@ -710,17 +719,17 @@ async def purchase_video(
@limiter.limit("4/minute")
async def revert_pending_purchase(
request: Request,
miner_hotkey: Annotated[str, Depends(get_hotkey)],
video: VideoPurchaseRevert,
db: Session = Depends(get_db),
):
# run with_lock True
return mark_video_submitted(db, video.video_id, True)
return mark_video_submitted(db, video.video_id, miner_hotkey, with_lock=True)

@app.post("/api/focus/verify-purchase")
@limiter.limit("4/minute")
async def verify_purchase(
request: Request,
miner_hotkey: Annotated[str, Body()],
miner_hotkey: Annotated[str, Depends(get_hotkey)],
video_id: Annotated[str, Body()],
block_hash: Annotated[str, Body()],
db: Session = Depends(get_db),
Expand Down
3 changes: 3 additions & 0 deletions validator-api/validator_api/cron/confirm_purchase.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import bittensor as bt

from validator_api.utils.wallet import get_transaction_from_block_hash
from validator_api.database.models.miner_bans import increment_failed_purchases, reset_failed_purchases

def extrinsic_already_confirmed(db: Session, extrinsic_id: str) -> bool:
record = db.query(FocusVideoRecord).filter(FocusVideoRecord.extrinsic_id == extrinsic_id)
Expand Down Expand Up @@ -146,6 +147,7 @@ async def confirm_video_purchased(

if video is not None and video.processing_state == FocusVideoStateInternal.PURCHASED:
print(f"Video <{video_id}> has been marked as PURCHASED. Stopping background task.")
reset_failed_purchases(db, video.miner_hotkey)
return True
elif video is not None and video.processing_state == FocusVideoStateInternal.SUBMITTED:
print(f"Video <{video_id}> has been marked as SUBMITTED. Stopping background task.")
Expand All @@ -161,6 +163,7 @@ async def confirm_video_purchased(
# we got here because we could not confirm the payment in time, so we need to revert
# the video back to the SUBMITTED state (i.e. mark available for purchase)
print(f"Video <{video_id}> has NOT been marked as PURCHASED. Reverting to SUBMITTED state...")
increment_failed_purchases(db, video.miner_hotkey)
video.processing_state = FocusVideoStateInternal.SUBMITTED
video.updated_at = datetime.utcnow()
db.add(video)
Expand Down
5 changes: 3 additions & 2 deletions validator-api/validator_api/database/crud/focusvideo.py
Original file line number Diff line number Diff line change
Expand Up @@ -453,12 +453,13 @@ def mark_video_rejected(
db.add(video_record)
db.commit()

def mark_video_submitted(db: Session, video_id: str, with_lock: bool = False):
def mark_video_submitted(db: Session, video_id: str, miner_hotkey: str, with_lock: bool = False):
# Mark video as "SUBMITTED" if in the "PURCHASE_PENDING" state.
video_record = db.query(FocusVideoRecord).filter(
FocusVideoRecord.video_id == video_id,
FocusVideoRecord.processing_state == FocusVideoStateInternal.PURCHASE_PENDING,
FocusVideoRecord.deleted_at.is_(None)
FocusVideoRecord.deleted_at.is_(None),
FocusVideoRecord.miner_hotkey == miner_hotkey # make sure the miner requesting the cancellation is the one who was trying to buy it!
)
if with_lock:
video_record = video_record.with_for_update()
Expand Down
93 changes: 93 additions & 0 deletions validator-api/validator_api/database/models/miner_bans.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
from datetime import datetime
from typing import Optional

from sqlalchemy import Column, String, DateTime, Integer
from pydantic import BaseModel, ConfigDict

from validator_api.database import Base
from validator_api.config import DB_STRING_LENGTH
from sqlalchemy.orm import Session
from datetime import timedelta

class MinerBan(Base):
__tablename__ = 'miner_bans'

miner_hotkey = Column(String(DB_STRING_LENGTH), primary_key=True, nullable=False)
purchases_failed_in_a_row = Column(Integer, nullable=False)
banned_until = Column(DateTime(timezone=True), nullable=True)

def miner_banned_until(db: Session, miner_hotkey: str) -> Optional[datetime]:
"""
Check if a miner is currently banned and return their ban expiration time if they are.

Args:
db: Database session
miner_hotkey: The miner's hotkey to check

Returns:
datetime: The banned_until time if the miner is currently banned
None: If the miner is not currently banned
"""
ban = db.query(MinerBan).filter(
MinerBan.miner_hotkey == miner_hotkey,
MinerBan.banned_until > datetime.utcnow()
).first()

return ban.banned_until if ban else None

def get_or_create_miner(db: Session, miner_hotkey: str) -> MinerBan:
"""
Get a miner's ban record or create it if it doesn't exist.

Args:
db: Database session
miner_hotkey: The miner's hotkey

Returns:
MinerBan: The miner's ban record
"""
miner = db.query(MinerBan).filter(
MinerBan.miner_hotkey == miner_hotkey
).first()

if not miner:
miner = MinerBan(
miner_hotkey=miner_hotkey,
purchases_failed_in_a_row=0,
banned_until=None
)
db.add(miner)
db.commit()

return miner

def increment_failed_purchases(db: Session, miner_hotkey: str):
"""
Increment the number of purchases failed in a row for a miner.
Creates the miner record if it doesn't exist.

"""
miner = get_or_create_miner(db, miner_hotkey)
miner.purchases_failed_in_a_row += 1
check_and_ban_miner(miner)
db.commit()

def reset_failed_purchases(db: Session, miner_hotkey: str):
"""
In the case of a successful purchase, reset the number of purchases failed in a row for a miner.
Creates the miner record if it doesn't exist.
"""
miner = get_or_create_miner(db, miner_hotkey)
miner.purchases_failed_in_a_row = 0
miner.banned_until = None
db.commit()

BAN_PURCHASES_FAILED_IN_A_ROW = 5
def check_and_ban_miner(miner: MinerBan):
"""
If a miner fails more than BAN_PURCHASES_FAILED_IN_A_ROW purchases in a row, ban them for 24 hours.
Creates the miner record if it doesn't exist.
"""
if miner.purchases_failed_in_a_row >= BAN_PURCHASES_FAILED_IN_A_ROW:
miner.purchases_failed_in_a_row = 0
miner.banned_until = datetime.utcnow() + timedelta(hours=24)