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
574 lines
20 KiB
Python
574 lines
20 KiB
Python
"""
|
|
Darkroom Token Admin Router
|
|
=============================
|
|
Admin-only endpoints for deploying, minting, and managing tokens
|
|
across multiple chains. Includes blacklist/anti-bot controls.
|
|
|
|
All endpoints require X-Admin-Key header.
|
|
|
|
Routes:
|
|
POST /api/v1/admin/tokens/deploy - Deploy new token
|
|
POST /api/v1/admin/tokens/mint - Mint additional tokens
|
|
POST /api/v1/admin/tokens/burn - Burn tokens
|
|
POST /api/v1/admin/tokens/transfer - Transfer ownership
|
|
POST /api/v1/admin/tokens/renounce - Renounce ownership
|
|
POST /api/v1/admin/tokens/blacklist - Add to blacklist
|
|
POST /api/v1/admin/tokens/unblacklist - Remove from blacklist
|
|
GET /api/v1/admin/tokens/list - List all deployments
|
|
GET /api/v1/admin/tokens/{id} - Get deployment details
|
|
GET /api/v1/admin/tokens/chains - List supported chains
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
|
|
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.token_deployer import (
|
|
DeployParams,
|
|
TokenDeployerFactory,
|
|
get_storage,
|
|
)
|
|
|
|
logger = logging.getLogger("darkroom_token_admin")
|
|
|
|
router = APIRouter(prefix="/api/v1/admin/tokens", tags=["darkroom-tokens"])
|
|
|
|
|
|
# ── Auth helper ───────────────────────────────────────────────
|
|
|
|
|
|
async def _verify_admin(request: Request):
|
|
"""Verify admin access with API key."""
|
|
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 DeployTokenRequest(BaseModel):
|
|
chain: str = Field(..., description="Chain: ethereum, base, bsc, solana, tron")
|
|
name: str = Field(..., min_length=1, max_length=50)
|
|
symbol: str = Field(..., min_length=1, max_length=10)
|
|
decimals: int = Field(default=18, ge=0, le=18)
|
|
initial_supply: str = Field(default="1000000")
|
|
max_supply: str | None = None
|
|
mintable: bool = True
|
|
burnable: bool = True
|
|
pausable: bool = False
|
|
owner_address: str | None = None
|
|
metadata_uri: str | None = None
|
|
# Blacklist / anti-bot
|
|
blacklist_enabled: bool = True
|
|
blacklist_addresses: list[str] = Field(default_factory=list)
|
|
max_wallet_limit: str | None = None
|
|
max_tx_limit: str | None = None
|
|
trading_enabled: bool = True
|
|
anti_bot_delay: int = 0
|
|
# Advanced
|
|
tax_rate: float | None = None
|
|
tax_recipient: str | None = None
|
|
deflationary: bool = False
|
|
|
|
|
|
class MintRequest(BaseModel):
|
|
deployment_id: str
|
|
to_address: str
|
|
amount: str
|
|
|
|
|
|
class BurnRequest(BaseModel):
|
|
deployment_id: str
|
|
amount: str
|
|
|
|
|
|
class TransferOwnershipRequest(BaseModel):
|
|
deployment_id: str
|
|
new_owner: str
|
|
|
|
|
|
class BlacklistRequest(BaseModel):
|
|
deployment_id: str
|
|
address: str
|
|
|
|
|
|
class SetLimitRequest(BaseModel):
|
|
deployment_id: str
|
|
amount: str
|
|
|
|
|
|
class TradingToggleRequest(BaseModel):
|
|
deployment_id: str
|
|
enabled: bool
|
|
|
|
|
|
# ── Endpoints ─────────────────────────────────────────────────
|
|
|
|
|
|
@router.get("/chains")
|
|
async def list_chains(request: Request, _=Depends(_verify_admin)):
|
|
"""List all supported chains and their configuration status."""
|
|
chains = TokenDeployerFactory.list_supported_chains()
|
|
return {
|
|
"chains": chains,
|
|
"total": len(chains),
|
|
"configured": sum(1 for c in chains if c["configured"]),
|
|
}
|
|
|
|
|
|
@router.post("/deploy")
|
|
async def deploy_token(request: Request, body: DeployTokenRequest, _=Depends(_verify_admin)):
|
|
"""Deploy a new token on the specified chain."""
|
|
try:
|
|
# Get deployer for chain
|
|
deployer = TokenDeployerFactory.get_deployer(body.chain)
|
|
|
|
# Build params
|
|
params = DeployParams(
|
|
chain=body.chain,
|
|
name=body.name,
|
|
symbol=body.symbol,
|
|
decimals=body.decimals,
|
|
initial_supply=body.initial_supply,
|
|
max_supply=body.max_supply,
|
|
mintable=body.mintable,
|
|
burnable=body.burnable,
|
|
pausable=body.pausable,
|
|
owner_address=body.owner_address or deployer.deployer_address,
|
|
metadata_uri=body.metadata_uri,
|
|
blacklist_enabled=body.blacklist_enabled,
|
|
blacklist_addresses=body.blacklist_addresses,
|
|
max_wallet_limit=body.max_wallet_limit,
|
|
max_tx_limit=body.max_tx_limit,
|
|
trading_enabled=body.trading_enabled,
|
|
anti_bot_delay=body.anti_bot_delay,
|
|
tax_rate=body.tax_rate,
|
|
tax_recipient=body.tax_recipient,
|
|
deflationary=body.deflationary,
|
|
)
|
|
|
|
# Deploy
|
|
deployment = await deployer.deploy_token(params)
|
|
|
|
# Apply initial blacklist if provided
|
|
if body.blacklist_addresses and body.blacklist_enabled:
|
|
for addr in body.blacklist_addresses:
|
|
try:
|
|
await deployer.blacklist_add(deployment.contract_address, addr)
|
|
logger.info(f"Blacklisted {addr} on {deployment.contract_address}")
|
|
except Exception as e:
|
|
logger.warning(f"Failed to blacklist {addr}: {e}")
|
|
|
|
# Apply max limits if set
|
|
if body.max_wallet_limit:
|
|
try:
|
|
await deployer.set_max_wallet(deployment.contract_address, body.max_wallet_limit)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to set max wallet: {e}")
|
|
|
|
if body.max_tx_limit:
|
|
try:
|
|
await deployer.set_max_tx(deployment.contract_address, body.max_tx_limit)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to set max tx: {e}")
|
|
|
|
# Save deployment record
|
|
storage = await get_storage()
|
|
await storage.save(deployment)
|
|
|
|
return {
|
|
"success": True,
|
|
"deployment": deployment.to_dict(),
|
|
"explorer_url": _get_explorer_url(body.chain, deployment.contract_address),
|
|
}
|
|
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
except Exception as e:
|
|
logger.error(f"Token deployment failed: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Deployment failed: {e!s}") from e
|
|
|
|
|
|
@router.post("/mint")
|
|
async def mint_tokens(request: Request, body: MintRequest, _=Depends(_verify_admin)):
|
|
"""Mint additional tokens for a deployed contract."""
|
|
try:
|
|
storage = await get_storage()
|
|
deployment = await storage.get(body.deployment_id)
|
|
if not deployment:
|
|
raise HTTPException(status_code=404, detail="Deployment not found")
|
|
|
|
if deployment.status != "deployed":
|
|
raise HTTPException(status_code=400, detail=f"Cannot mint - status is {deployment.status}")
|
|
|
|
deployer = TokenDeployerFactory.get_deployer(deployment.chain)
|
|
tx_hash = await deployer.mint_tokens(deployment.contract_address, body.to_address, body.amount)
|
|
|
|
await storage.update_status(body.deployment_id, "minting", {"last_mint_tx": tx_hash})
|
|
|
|
return {
|
|
"success": True,
|
|
"tx_hash": tx_hash,
|
|
"explorer_url": _get_explorer_url(deployment.chain, tx_hash, is_tx=True),
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Mint failed: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Mint failed: {e!s}") from e
|
|
|
|
|
|
@router.post("/burn")
|
|
async def burn_tokens(request: Request, body: BurnRequest, _=Depends(_verify_admin)):
|
|
"""Burn tokens from deployer balance."""
|
|
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.burn_tokens(deployment.contract_address, body.amount)
|
|
|
|
await storage.update_status(body.deployment_id, "burned", {"last_burn_tx": tx_hash})
|
|
|
|
return {
|
|
"success": True,
|
|
"tx_hash": tx_hash,
|
|
"explorer_url": _get_explorer_url(deployment.chain, tx_hash, is_tx=True),
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Burn failed: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Burn failed: {e!s}") from e
|
|
|
|
|
|
@router.post("/transfer")
|
|
async def transfer_ownership(request: Request, body: TransferOwnershipRequest, _=Depends(_verify_admin)):
|
|
"""Transfer contract ownership to a new address."""
|
|
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.transfer_ownership(deployment.contract_address, body.new_owner)
|
|
|
|
await storage.update_status(
|
|
body.deployment_id,
|
|
"ownership_transferred",
|
|
{"new_owner": body.new_owner, "transfer_tx": tx_hash},
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"tx_hash": tx_hash,
|
|
"new_owner": body.new_owner,
|
|
"explorer_url": _get_explorer_url(deployment.chain, tx_hash, is_tx=True),
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Ownership transfer failed: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Transfer failed: {e!s}") from e
|
|
|
|
|
|
@router.post("/renounce")
|
|
async def renounce_ownership(request: Request, body: dict = Body(...), _=Depends(_verify_admin)):
|
|
"""Renounce contract ownership (make immutable). WARNING: Irreversible."""
|
|
deployment_id = body.get("deployment_id", "")
|
|
|
|
try:
|
|
storage = await get_storage()
|
|
deployment = await storage.get(deployment_id)
|
|
if not deployment:
|
|
raise HTTPException(status_code=404, detail="Deployment not found")
|
|
|
|
deployer = TokenDeployerFactory.get_deployer(deployment.chain)
|
|
tx_hash = await deployer.renounce_ownership(deployment.contract_address)
|
|
|
|
await storage.update_status(deployment_id, "ownership_renounced", {"renounce_tx": tx_hash})
|
|
|
|
return {
|
|
"success": True,
|
|
"tx_hash": tx_hash,
|
|
"warning": "Ownership renounced. Contract is now immutable.",
|
|
"explorer_url": _get_explorer_url(deployment.chain, tx_hash, is_tx=True),
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Renounce failed: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Renounce failed: {e!s}") from e
|
|
|
|
|
|
# ── Blacklist / Anti-Bot Endpoints ────────────────────────────
|
|
|
|
|
|
@router.post("/blacklist")
|
|
async def blacklist_add(request: Request, body: BlacklistRequest, _=Depends(_verify_admin)):
|
|
"""Add an address to the token blacklist."""
|
|
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.blacklist_add(deployment.contract_address, body.address)
|
|
|
|
return {
|
|
"success": True,
|
|
"tx_hash": tx_hash,
|
|
"address": body.address,
|
|
"action": "blacklisted",
|
|
"explorer_url": _get_explorer_url(deployment.chain, tx_hash, is_tx=True),
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Blacklist add failed: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Blacklist failed: {e!s}") from e
|
|
|
|
|
|
@router.post("/unblacklist")
|
|
async def blacklist_remove(request: Request, body: BlacklistRequest, _=Depends(_verify_admin)):
|
|
"""Remove an address from the token blacklist."""
|
|
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.blacklist_remove(deployment.contract_address, body.address)
|
|
|
|
return {
|
|
"success": True,
|
|
"tx_hash": tx_hash,
|
|
"address": body.address,
|
|
"action": "unblacklisted",
|
|
"explorer_url": _get_explorer_url(deployment.chain, tx_hash, is_tx=True),
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Unblacklist failed: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Unblacklist failed: {e!s}") from e
|
|
|
|
|
|
@router.get("/blacklist/check")
|
|
async def check_blacklist(request: Request, deployment_id: str, address: str, _=Depends(_verify_admin)):
|
|
"""Check if an address is blacklisted."""
|
|
try:
|
|
storage = await get_storage()
|
|
deployment = await storage.get(deployment_id)
|
|
if not deployment:
|
|
raise HTTPException(status_code=404, detail="Deployment not found")
|
|
|
|
deployer = TokenDeployerFactory.get_deployer(deployment.chain)
|
|
is_blacklisted = await deployer.is_blacklisted(deployment.contract_address, address)
|
|
|
|
return {
|
|
"address": address,
|
|
"is_blacklisted": is_blacklisted,
|
|
"contract": deployment.contract_address,
|
|
"chain": deployment.chain,
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Blacklist check failed: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Check failed: {e!s}") from e
|
|
|
|
|
|
@router.post("/trading")
|
|
async def set_trading(request: Request, body: TradingToggleRequest, _=Depends(_verify_admin)):
|
|
"""Enable or disable trading for the 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)
|
|
tx_hash = await deployer.set_trading_enabled(deployment.contract_address, body.enabled)
|
|
|
|
return {
|
|
"success": True,
|
|
"tx_hash": tx_hash,
|
|
"trading_enabled": body.enabled,
|
|
"explorer_url": _get_explorer_url(deployment.chain, tx_hash, is_tx=True),
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Trading toggle failed: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Toggle failed: {e!s}") from e
|
|
|
|
|
|
@router.post("/max-wallet")
|
|
async def set_max_wallet(request: Request, body: SetLimitRequest, _=Depends(_verify_admin)):
|
|
"""Set maximum wallet holding limit."""
|
|
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_max_wallet(deployment.contract_address, body.amount)
|
|
|
|
return {
|
|
"success": True,
|
|
"tx_hash": tx_hash,
|
|
"max_wallet": body.amount,
|
|
"explorer_url": _get_explorer_url(deployment.chain, tx_hash, is_tx=True),
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Max wallet set failed: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Set failed: {e!s}") from e
|
|
|
|
|
|
@router.post("/max-tx")
|
|
async def set_max_tx(request: Request, body: SetLimitRequest, _=Depends(_verify_admin)):
|
|
"""Set maximum transaction limit."""
|
|
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_max_tx(deployment.contract_address, body.amount)
|
|
|
|
return {
|
|
"success": True,
|
|
"tx_hash": tx_hash,
|
|
"max_tx": body.amount,
|
|
"explorer_url": _get_explorer_url(deployment.chain, tx_hash, is_tx=True),
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Max tx set failed: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Set failed: {e!s}") from e
|
|
|
|
|
|
# ── Query Endpoints ───────────────────────────────────────────
|
|
|
|
|
|
@router.get("/list")
|
|
async def list_deployments(request: Request, chain: str | None = None, limit: int = 100, _=Depends(_verify_admin)):
|
|
"""List all token deployments."""
|
|
try:
|
|
storage = await get_storage()
|
|
deployments = await storage.list_all(chain=chain, limit=limit)
|
|
|
|
return {
|
|
"deployments": [d.to_dict() for d in deployments],
|
|
"total": len(deployments),
|
|
"chain_filter": chain,
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"List failed: {e}")
|
|
raise HTTPException(status_code=500, detail=f"List failed: {e!s}") from e
|
|
|
|
|
|
@router.get("/{deployment_id}")
|
|
async def get_deployment(request: Request, deployment_id: str, _=Depends(_verify_admin)):
|
|
"""Get deployment details by ID."""
|
|
try:
|
|
storage = await get_storage()
|
|
deployment = await storage.get(deployment_id)
|
|
if not deployment:
|
|
raise HTTPException(status_code=404, detail="Deployment not found")
|
|
|
|
# Get on-chain info
|
|
try:
|
|
deployer = TokenDeployerFactory.get_deployer(deployment.chain)
|
|
token_info = await deployer.get_token_info(deployment.contract_address)
|
|
except Exception:
|
|
token_info = {}
|
|
|
|
return {
|
|
"deployment": deployment.to_dict(),
|
|
"on_chain_info": token_info,
|
|
"explorer_url": _get_explorer_url(deployment.chain, deployment.contract_address),
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Get deployment failed: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Get failed: {e!s}") from e
|
|
|
|
|
|
@router.get("/{deployment_id}/balance/{wallet_address}")
|
|
async def get_balance(request: Request, deployment_id: str, wallet_address: str, _=Depends(_verify_admin)):
|
|
"""Get token balance for a specific wallet."""
|
|
try:
|
|
storage = await get_storage()
|
|
deployment = await storage.get(deployment_id)
|
|
if not deployment:
|
|
raise HTTPException(status_code=404, detail="Deployment not found")
|
|
|
|
deployer = TokenDeployerFactory.get_deployer(deployment.chain)
|
|
balance = await deployer.get_balance(deployment.contract_address, wallet_address)
|
|
|
|
return {
|
|
"wallet": wallet_address,
|
|
"balance": balance,
|
|
"contract": deployment.contract_address,
|
|
"chain": deployment.chain,
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Balance check failed: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Balance check failed: {e!s}") from e
|
|
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────
|
|
|
|
|
|
def _get_explorer_url(chain: str, address_or_tx: str, is_tx: bool = False) -> str:
|
|
"""Get block explorer URL for a chain."""
|
|
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}"
|