Some checks failed
CI / build (push) Failing after 3s
PHASE 2.3 (AUDIT-2026-Q3.md):
Task 1 — Wire-in Wave 3 (1 router mounted, 2 deferred):
- app.routers.unified_scanner_router mounted at /api/v2/scanner/* (2 routes:
POST /api/v2/scanner/token/scan, POST /api/v2/scanner/wallet/scan).
Refactored prefix from /api/v2 -> /api/v2/scanner to avoid future conflicts
with the v1 /api/v1/scanner/ stub.
- app.routers.unified_wallet_scanner DEFERRED (no router APIRouter attribute;
library module consumed by unified_scanner_router via get_wallet_scanner()).
- app.routers.admin_extensions DEFERRED (DORMANT per audit; 25 routes at
/api/v1/admin/* would shadow /api/v1/admin/alerts_webhook).
Task 2 — Archive 136 dead-code files to app/_archive/legacy_2026_07/:
- 73 routers in app/routers/ (reach graph showed zero reach into mount.py).
- 63 flat app/*.py (domain modules never imported by live code).
- 1 file RESTORED post-archive: app/routers/x402_bridge_health.py (caught by
tests/unit/test_bridge_health.py which directly imports it; reach graph
considered tests/ only as transitive reach — to be patched in next cycle).
Forced-LIVE (NOT archived per user directive):
- app/ai_pipeline_v3.py (3 importers in audit window, importers themselves DEAD)
- app/splade_bm25.py (LIVE via app.rag_service)
- app/wallet_manager_v2.py (LIVE via x402_enforcement, x402_tools, sweep_all, sweep_now)
- app/crypto_embeddings.py (NOT in audit ARCHIVE list; heavy import graph)
Verification (forward-import closure from mount.py + main.py + factory.py + lifespan.py):
- imports = 348 app.* modules
- reached = 194 files reachable from roots
- archive set = audit_dead (186) - reached - forced_live (4) - test_live (1) = 136
- Net delta: 136 files moved, 44,932 LOC reduction, 293->295 active routes (+2 from Wave 3)
pyproject.toml updates:
- setuptools.packages.find: added exclude for app._archive*
- ruff.extend-exclude: added "app/_archive/"
- mypy.exclude: added "app/_archive/"
Smoke test: pytest tests/ — 817 passed, 3 pre-existing failures unchanged
(0 new failures; 0 routes lost; all 4 forced-LIVE files still importable).
Restoration: git mv app/_archive/legacy_2026_07/<name>.py <original-path>
and add the import to app/mount.py ROUTER_MODULES.
Refs: AUDIT-2026-Q3.md /home/dev/pry/rmi-final-deadcode-2026-07-06.md
497 lines
18 KiB
Python
497 lines
18 KiB
Python
"""
|
|
Darkroom Airdrop Admin Router
|
|
==============================
|
|
Admin-only endpoints for airdrop operations:
|
|
• Snapshot creation from existing tokens
|
|
• Team/dev token allocation with vesting
|
|
• Anti-sniper protection configuration
|
|
• Batch airdrop execution
|
|
• Vesting management
|
|
|
|
All endpoints require X-Admin-Key header.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.airdrop_engine import (
|
|
AirdropCampaign,
|
|
AirdropDistributor,
|
|
AirdropStorage,
|
|
AntiSniperProtection,
|
|
SnapshotEngine,
|
|
TeamAllocation,
|
|
create_full_token_with_protection,
|
|
)
|
|
from app.token_deployer import TokenDeployerFactory, get_storage
|
|
|
|
logger = logging.getLogger("darkroom_airdrop_admin")
|
|
|
|
router = APIRouter(prefix="/api/v1/admin/tokens/airdrop", tags=["darkroom-airdrop"])
|
|
|
|
|
|
# ── Auth helper ───────────────────────────────────────────────
|
|
|
|
|
|
async def _verify_admin(request: Request):
|
|
admin_key = os.getenv("ADMIN_API_KEY", "dev-key-change-me")
|
|
key = request.headers.get("X-Admin-Key", "")
|
|
if key != admin_key:
|
|
raise HTTPException(status_code=401, detail="Invalid admin key")
|
|
return True
|
|
|
|
|
|
# ── Request Models ────────────────────────────────────────────
|
|
|
|
|
|
class SnapshotRequest(BaseModel):
|
|
source_token: str
|
|
source_chain: str = Field(..., description="ethereum, base, bsc, solana")
|
|
min_holdings: str = "0"
|
|
excluded_addresses: list[str] = Field(default_factory=list)
|
|
block_number: int | None = None
|
|
|
|
|
|
class AirdropExecuteRequest(BaseModel):
|
|
deployment_id: str
|
|
snapshot_id: str | None = None
|
|
distribution_type: str = "snapshot" # snapshot, manual, team
|
|
recipients: list[dict[str, str]] = Field(default_factory=list)
|
|
# For manual: [{"address": "0x...", "amount": "1000", "reason": "marketing"}]
|
|
|
|
|
|
class TeamAllocationRequest(BaseModel):
|
|
deployment_id: str
|
|
allocations: list[dict[str, Any]] = Field(
|
|
...,
|
|
description="""
|
|
[
|
|
{"address": "0x...", "role": "dev", "percent": 15, "immediate": 20,
|
|
"vested": 80, "cliff_months": 6, "vesting_months": 24},
|
|
{"address": "0x...", "role": "marketing", "percent": 10, "immediate": 50,
|
|
"vested": 50, "cliff_months": 3, "vesting_months": 12}
|
|
]
|
|
""",
|
|
)
|
|
|
|
|
|
class AntiSniperRequest(BaseModel):
|
|
deployment_id: str
|
|
blacklist_addresses: list[str] = Field(default_factory=list)
|
|
trading_delay_blocks: int = 0
|
|
max_wallet_percent: float = 2.0
|
|
max_tx_percent: float = 1.0
|
|
enable_trading_at_block: int | None = None
|
|
|
|
|
|
class FullLaunchRequest(BaseModel):
|
|
chain: str
|
|
name: str
|
|
symbol: str
|
|
decimals: int = 18
|
|
initial_supply: str = "1000000000" # 1B default
|
|
team_allocations: list[dict[str, Any]] = Field(default_factory=list)
|
|
airdrop_source_token: str | None = None
|
|
airdrop_source_chain: str | None = None
|
|
anti_sniper: bool = True
|
|
trading_delay_blocks: int = 0
|
|
max_wallet_percent: float = 2.0
|
|
max_tx_percent: float = 1.0
|
|
blacklist_addresses: list[str] = Field(default_factory=list)
|
|
# Quick team presets
|
|
dev_percent: float | None = None
|
|
marketing_percent: float | None = None
|
|
treasury_percent: float | None = None
|
|
airdrop_percent: float | None = None
|
|
dev_address: str | None = None
|
|
marketing_address: str | None = None
|
|
treasury_address: str | None = None
|
|
|
|
|
|
class VestingClaimRequest(BaseModel):
|
|
schedule_id: str
|
|
|
|
|
|
class EnableTradingRequest(BaseModel):
|
|
deployment_id: str
|
|
|
|
|
|
# ── Endpoints ─────────────────────────────────────────────────
|
|
|
|
|
|
@router.post("/snapshot")
|
|
async def create_snapshot(request: Request, body: SnapshotRequest, _=Depends(_verify_admin)):
|
|
"""Create a snapshot of token holders for airdrop eligibility."""
|
|
try:
|
|
if body.source_chain in ["ethereum", "base", "bsc"]:
|
|
rpc = os.getenv(f"{body.source_chain.upper()}_RPC_URL", "")
|
|
if not rpc:
|
|
raise HTTPException(status_code=400, detail=f"No RPC configured for {body.source_chain}")
|
|
|
|
snapshot = await SnapshotEngine.create_evm_snapshot(
|
|
token_address=body.source_token,
|
|
chain=body.source_chain,
|
|
rpc_url=rpc,
|
|
min_holdings=body.min_holdings,
|
|
excluded_addresses=body.excluded_addresses,
|
|
block_number=body.block_number,
|
|
)
|
|
elif body.source_chain == "solana":
|
|
rpc = os.getenv("SOLANA_RPC_URL", "")
|
|
if not rpc:
|
|
raise HTTPException(status_code=400, detail="No Solana RPC configured")
|
|
|
|
snapshot = await SnapshotEngine.create_solana_snapshot(
|
|
token_address=body.source_token,
|
|
rpc_url=rpc,
|
|
min_holdings=body.min_holdings,
|
|
excluded_addresses=body.excluded_addresses,
|
|
)
|
|
else:
|
|
raise HTTPException(status_code=400, detail=f"Unsupported chain: {body.source_chain}")
|
|
|
|
await AirdropStorage.save_snapshot(snapshot)
|
|
|
|
return {
|
|
"success": True,
|
|
"snapshot": {
|
|
"snapshot_id": snapshot.snapshot_id,
|
|
"source_token": snapshot.source_token,
|
|
"source_chain": snapshot.source_chain,
|
|
"block_number": snapshot.block_number,
|
|
"total_holders": snapshot.total_holders,
|
|
"total_supply_snapshotted": snapshot.total_supply_snapshotted,
|
|
"timestamp": snapshot.timestamp,
|
|
},
|
|
"holders_preview": [{"address": h.address, "amount": h.amount} for h in snapshot.holders[:10]],
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Snapshot failed: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Snapshot failed: {e!s}") from e
|
|
|
|
|
|
@router.post("/execute")
|
|
async def execute_airdrop(request: Request, body: AirdropExecuteRequest, _=Depends(_verify_admin)):
|
|
"""Execute airdrop distribution."""
|
|
try:
|
|
storage = await get_storage()
|
|
deployment = await storage.get(body.deployment_id)
|
|
if not deployment:
|
|
raise HTTPException(status_code=404, detail="Deployment not found")
|
|
|
|
deployer = TokenDeployerFactory.get_deployer(deployment.chain)
|
|
|
|
# Get recipients
|
|
recipients = []
|
|
if body.snapshot_id:
|
|
# Load from snapshot
|
|
snapshot_data = None
|
|
if storage.redis:
|
|
data = await storage.redis.get(f"airdrop_snapshot:{body.snapshot_id}")
|
|
if data:
|
|
snapshot_data = json.loads(data)
|
|
|
|
if not snapshot_data:
|
|
raise HTTPException(status_code=404, detail="Snapshot not found")
|
|
|
|
from app.airdrop_engine import AirdropRecipient
|
|
|
|
recipients = [AirdropRecipient(**r) for r in snapshot_data.get("holders", [])]
|
|
elif body.recipients:
|
|
from app.airdrop_engine import AirdropRecipient
|
|
|
|
recipients = [AirdropRecipient(**r) for r in body.recipients]
|
|
else:
|
|
raise HTTPException(status_code=400, detail="No recipients provided (need snapshot_id or recipients)")
|
|
|
|
# Execute based on chain
|
|
if deployment.chain in ["ethereum", "base", "bsc"]:
|
|
result = await AirdropDistributor.execute_evm_airdrop(deployer, deployment.contract_address, recipients)
|
|
elif deployment.chain == "solana":
|
|
result = await AirdropDistributor.execute_solana_airdrop(deployer, deployment.contract_address, recipients)
|
|
else:
|
|
raise HTTPException(status_code=400, detail=f"Airdrop not supported for {deployment.chain}")
|
|
|
|
# Save campaign
|
|
campaign = AirdropCampaign(
|
|
campaign_id=f"airdrop_{body.deployment_id}_{int(time.time())}",
|
|
deployment_id=body.deployment_id,
|
|
snapshot_id=body.snapshot_id or "manual",
|
|
chain=deployment.chain,
|
|
status="completed" if result["failed"] == 0 else "partial",
|
|
distribution_type=body.distribution_type,
|
|
recipients=recipients,
|
|
total_amount=str(sum(int(r.amount) for r in recipients)),
|
|
distributed_amount=str(sum(int(r.amount) for r in recipients if r.claimed)),
|
|
tx_hashes=result["tx_hashes"],
|
|
)
|
|
await AirdropStorage.save_campaign(campaign)
|
|
|
|
return {
|
|
"success": True,
|
|
"campaign_id": campaign.campaign_id,
|
|
"result": result,
|
|
"total_recipients": len(recipients),
|
|
"distributed": campaign.distributed_amount,
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Airdrop execution failed: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Airdrop failed: {e!s}") from e
|
|
|
|
|
|
@router.post("/team")
|
|
async def allocate_team(request: Request, body: TeamAllocationRequest, _=Depends(_verify_admin)):
|
|
"""Allocate team/dev tokens with optional vesting."""
|
|
try:
|
|
storage = await get_storage()
|
|
deployment = await storage.get(body.deployment_id)
|
|
if not deployment:
|
|
raise HTTPException(status_code=404, detail="Deployment not found")
|
|
|
|
deployer = TokenDeployerFactory.get_deployer(deployment.chain)
|
|
|
|
result = await TeamAllocation.allocate_team_tokens(deployer, deployment, body.allocations)
|
|
|
|
return {
|
|
"success": True,
|
|
"deployment_id": body.deployment_id,
|
|
"allocations": result["allocations"],
|
|
"total_allocated": str(result["total_allocated"]),
|
|
"tx_hashes": result["tx_hashes"],
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Team allocation failed: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Team allocation failed: {e!s}") from e
|
|
|
|
|
|
@router.post("/antisniper")
|
|
async def apply_antisniper(request: Request, body: AntiSniperRequest, _=Depends(_verify_admin)):
|
|
"""Apply anti-sniper protection to a deployed token."""
|
|
try:
|
|
storage = await get_storage()
|
|
deployment = await storage.get(body.deployment_id)
|
|
if not deployment:
|
|
raise HTTPException(status_code=404, detail="Deployment not found")
|
|
|
|
deployer = TokenDeployerFactory.get_deployer(deployment.chain)
|
|
|
|
result = await AntiSniperProtection.apply_protection(
|
|
deployer,
|
|
deployment.contract_address,
|
|
deployment,
|
|
blacklist_addresses=body.blacklist_addresses,
|
|
trading_delay_blocks=body.trading_delay_blocks,
|
|
max_wallet_percent=body.max_wallet_percent,
|
|
max_tx_percent=body.max_tx_percent,
|
|
)
|
|
|
|
# Update deployment record
|
|
await storage.update_status(body.deployment_id, "anti_sniper_applied", {"anti_sniper": result})
|
|
|
|
return {
|
|
"success": True,
|
|
"deployment_id": body.deployment_id,
|
|
"protection_applied": result,
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Anti-sniper failed: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Anti-sniper failed: {e!s}") from e
|
|
|
|
|
|
@router.post("/launch")
|
|
async def full_launch(request: Request, body: FullLaunchRequest, _=Depends(_verify_admin)):
|
|
"""
|
|
Full token launch with everything: deploy, anti-sniper, team allocation, airdrop.
|
|
This is the one-shot launch endpoint for CRM v2 or any new token.
|
|
"""
|
|
try:
|
|
# Build team allocations from quick presets if provided
|
|
team_allocs = body.team_allocations or []
|
|
|
|
if body.dev_percent and body.dev_address:
|
|
team_allocs.append(
|
|
{
|
|
"address": body.dev_address,
|
|
"role": "development",
|
|
"percent": body.dev_percent,
|
|
"immediate": 20,
|
|
"vested": 80,
|
|
"cliff_months": 6,
|
|
"vesting_months": 24,
|
|
}
|
|
)
|
|
|
|
if body.marketing_percent and body.marketing_address:
|
|
team_allocs.append(
|
|
{
|
|
"address": body.marketing_address,
|
|
"role": "marketing",
|
|
"percent": body.marketing_percent,
|
|
"immediate": 50,
|
|
"vested": 50,
|
|
"cliff_months": 3,
|
|
"vesting_months": 12,
|
|
}
|
|
)
|
|
|
|
if body.treasury_percent and body.treasury_address:
|
|
team_allocs.append(
|
|
{
|
|
"address": body.treasury_address,
|
|
"role": "treasury",
|
|
"percent": body.treasury_percent,
|
|
"immediate": 0,
|
|
"vested": 100,
|
|
"cliff_months": 12,
|
|
"vesting_months": 36,
|
|
}
|
|
)
|
|
|
|
result = await create_full_token_with_protection(
|
|
chain=body.chain,
|
|
name=body.name,
|
|
symbol=body.symbol,
|
|
decimals=body.decimals,
|
|
initial_supply=body.initial_supply,
|
|
team_allocations=team_allocs,
|
|
airdrop_source_token=body.airdrop_source_token,
|
|
airdrop_source_chain=body.airdrop_source_chain,
|
|
anti_sniper=body.anti_sniper,
|
|
trading_delay_blocks=body.trading_delay_blocks,
|
|
max_wallet_percent=body.max_wallet_percent,
|
|
max_tx_percent=body.max_tx_percent,
|
|
blacklist_addresses=body.blacklist_addresses,
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"launch_complete": True,
|
|
"deployment": result.get("deployment"),
|
|
"anti_sniper": result.get("anti_sniper"),
|
|
"team_allocation": result.get("team_allocation"),
|
|
"airdrop": result.get("airdrop"),
|
|
"errors": result.get("errors", []),
|
|
"explorer_url": _get_explorer_url(body.chain, result["deployment"]["contract_address"])
|
|
if result.get("deployment")
|
|
else None,
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Full launch failed: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Launch failed: {e!s}") from e
|
|
|
|
|
|
@router.post("/trading/enable")
|
|
async def enable_trading(request: Request, body: EnableTradingRequest, _=Depends(_verify_admin)):
|
|
"""Enable trading for a token (after anti-sniper delay)."""
|
|
try:
|
|
storage = await get_storage()
|
|
deployment = await storage.get(body.deployment_id)
|
|
if not deployment:
|
|
raise HTTPException(status_code=404, detail="Deployment not found")
|
|
|
|
deployer = TokenDeployerFactory.get_deployer(deployment.chain)
|
|
tx_hash = await deployer.set_trading_enabled(deployment.contract_address, True)
|
|
|
|
await storage.update_status(body.deployment_id, "trading_enabled", {"trading_enabled_tx": tx_hash})
|
|
|
|
return {
|
|
"success": True,
|
|
"tx_hash": tx_hash,
|
|
"trading_enabled": True,
|
|
"explorer_url": _get_explorer_url(deployment.chain, tx_hash, is_tx=True),
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Enable trading failed: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Enable trading failed: {e!s}") from e
|
|
|
|
|
|
@router.get("/campaign/{campaign_id}")
|
|
async def get_campaign(request: Request, campaign_id: str, _=Depends(_verify_admin)):
|
|
"""Get airdrop campaign status."""
|
|
try:
|
|
campaign = await AirdropStorage.get_campaign(campaign_id)
|
|
if not campaign:
|
|
raise HTTPException(status_code=404, detail="Campaign not found")
|
|
|
|
return {
|
|
"campaign": campaign.to_dict(),
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Get campaign failed: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Get failed: {e!s}") from e
|
|
|
|
|
|
@router.get("/vesting/{schedule_id}")
|
|
async def get_vesting(request: Request, schedule_id: str, _=Depends(_verify_admin)):
|
|
"""Get vesting schedule details."""
|
|
try:
|
|
from app.token_deployer import get_storage
|
|
|
|
storage = await get_storage()
|
|
|
|
data = None
|
|
if storage.redis:
|
|
data = await storage.redis.get(f"vesting:{schedule_id}")
|
|
|
|
if not data:
|
|
raise HTTPException(status_code=404, detail="Vesting schedule not found")
|
|
|
|
return {
|
|
"vesting": json.loads(data),
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Get vesting failed: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Get failed: {e!s}") from e
|
|
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────
|
|
|
|
|
|
def _get_explorer_url(chain: str, address_or_tx: str, is_tx: bool = False) -> str:
|
|
explorers = {
|
|
"ethereum": "https://etherscan.io",
|
|
"base": "https://basescan.org",
|
|
"bsc": "https://bscscan.com",
|
|
"solana": "https://solscan.io",
|
|
"tron": "https://tronscan.org/#",
|
|
}
|
|
base = explorers.get(chain, "")
|
|
if not base:
|
|
return ""
|
|
|
|
if chain == "tron":
|
|
path = "transaction" if is_tx else "contract"
|
|
return f"{base}/{path}/{address_or_tx}"
|
|
|
|
path = "tx" if is_tx else "address"
|
|
return f"{base}/{path}/{address_or_tx}"
|