rmi-backend/app/multichain_airdrop.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- Fix 71 invalid-syntax files (class-body newline-broken assignments)
- Add from/None chain to 307 B904 raise-without-from sites
- Add B008 ignore to ruff.toml (already in pyproject.toml)
- Noqa F401 on __init__.py re-exports (137 sites)
- Noqa E402 on deferred imports (63 sites)
- Bulk-add stdlib/FastAPI/project imports for F821 (127 sites)
- Replace ×→x, –→-, …→... in docstrings (4093 chars)
- Manual refactor of 5 SIM103/SIM116 patterns

Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py)
Co-authored-by: opencode <opencode@rugmunch.io>
2026-07-06 15:43:20 +02:00

1124 lines
39 KiB
Python

"""
Darkroom Multi-Chain Airdrop Engine
====================================
Cross-chain holder aggregation for airdrop qualification.
Supports:
• Multiple source chains per campaign (e.g., Base + Solana)
• Multiple source tokens per chain (e.g., CRM + CryptoRugMunch)
• Weighted allocation based on holdings across ALL chains
• Wiggle room: minimum thresholds, tiered rewards, deduplication
• Cross-chain snapshot merging with anti-gaming protections
Use case:
"Airdrop CRMv2 to holders of CRM on Base OR CryptoRugMunch on Solana
with 2x weight for Base holders, 1x for Solana, minimum 1000 tokens"
Architecture:
MultiChainSnapshotEngine -> aggregates holders from N chains
WeightedAirdropCalculator -> computes allocation based on rules
CrossChainDeduplicator -> prevents double-dipping same wallet
AirdropCampaignManager -> full lifecycle from snapshot to distribution
"""
from __future__ import annotations
import json
import logging
import os
import time
from dataclasses import asdict, dataclass, field
from datetime import datetime
logger = logging.getLogger("darkroom_multichain_airdrop")
# ── Data Models ───────────────────────────────────────────────
@dataclass
class TokenSource:
"""A token on a specific chain that qualifies for airdrop."""
chain: str
token_address: str
token_symbol: str
weight_multiplier: float = 1.0
min_holdings: str = "0"
max_holdings: str | None = None
holding_period_days: int | None = None # Must hold for N days
is_nft: bool = False
nft_collection_id: str | None = None
@dataclass
class CrossChainHolder:
"""Aggregated holder data across multiple chains."""
# Primary identifier - can be EVM address, Solana pubkey, or linked identity
primary_address: str
chain_addresses: dict[str, str] = field(default_factory=dict)
# { "base": "0x123...", "solana": "ABC...", "ethereum": "0x456..." }
holdings_per_source: dict[str, str] = field(default_factory=dict)
# { "base:CRM": "1000000", "solana:CryptoRugMunch": "500000" }
weighted_score: float = 0.0
total_tokens_qualified: int = 0
airdrop_allocation: str = "0"
# Anti-gaming
is_contract: bool = False
is_known_bot: bool = False
wallet_age_days: int | None = None
first_seen: str | None = None
tx_count_30d: int = 0
def to_dict(self) -> dict:
return asdict(self)
@dataclass
class AirdropTier:
"""Tiered reward structure based on holdings."""
name: str
min_score: float
max_score: float | None = None
base_allocation: str = "0"
bonus_multiplier: float = 1.0
description: str = ""
@dataclass
class MultiChainAirdropCampaign:
"""Full multi-chain airdrop campaign configuration."""
campaign_id: str
target_chain: str # Where airdrop token is deployed
target_token: str # Contract address of airdrop token
# Source qualification criteria
source_tokens: list[TokenSource] = field(default_factory=list)
# Allocation rules
total_airdrop_pool: str = "0"
distribution_mode: str = "proportional" # proportional, tiered, fixed
tiers: list[AirdropTier] = field(default_factory=list)
# Wiggle room / flexibility
min_holdings_for_qualification: str = "0"
max_holdings_cap: str | None = None
require_hold_both_chains: bool = False # If True, must hold on ALL specified chains
require_hold_any_chain: bool = True # If True, hold on ANY qualifies
cross_chain_bonus: float = 0.0 # Bonus % for holding on multiple chains
# Anti-gaming
exclude_contracts: bool = True
exclude_known_bots: bool = True
min_wallet_age_days: int | None = None
min_tx_count_30d: int | None = None
snapshot_time_window_hours: int = 24
# Status
status: str = "draft"
created_at: str = field(default_factory=lambda: datetime.utcnow().isoformat())
snapshot_completed_at: str | None = None
distribution_completed_at: str | None = None
# Results
qualified_holders: list[CrossChainHolder] = field(default_factory=list)
total_qualified: int = 0
total_distributed: str = "0"
tx_hashes: list[str] = field(default_factory=list)
def to_dict(self) -> dict:
return asdict(self)
# ── Cross-Chain Snapshot Engine ───────────────────────────────
class MultiChainSnapshotEngine:
"""
Aggregate token holders across multiple chains and tokens.
Example:
sources = [
TokenSource("base", "0xCRM...", "CRM", weight=2.0, min_holdings="1000"),
TokenSource("solana", "CRMsa...", "CRM", weight=1.5, min_holdings="1000"),
TokenSource("solana", "CRMspl...", "CryptoRugMunch", weight=1.0, min_holdings="1"),
]
"""
@staticmethod
async def create_multichain_snapshot(
sources: list[TokenSource],
exclude_addresses: list[str] | None = None,
) -> dict[str, CrossChainHolder]:
"""
Create a merged snapshot of all holders across all sources.
Returns dict keyed by primary_address.
"""
all_holders: dict[str, CrossChainHolder] = {}
excluded = {a.lower() for a in (exclude_addresses or [])}
for source in sources:
logger.info(f"Snapshotting {source.token_symbol} on {source.chain} at {source.token_address}")
# Get holders for this source
holders = await MultiChainSnapshotEngine._get_holders_for_source(source)
for addr, amount in holders.items():
if addr.lower() in excluded:
continue
# Create or update cross-chain holder
if addr not in all_holders:
all_holders[addr] = CrossChainHolder(
primary_address=addr,
chain_addresses={source.chain: addr},
holdings_per_source={},
)
# Record holdings for this source
source_key = f"{source.chain}:{source.token_symbol}"
all_holders[addr].holdings_per_source[source_key] = amount
# Update chain address mapping
all_holders[addr].chain_addresses[source.chain] = addr
logger.info(f"Multi-chain snapshot: {len(all_holders)} unique holders across {len(sources)} sources")
return all_holders
@staticmethod
async def _get_holders_for_source(source: TokenSource) -> dict[str, str]:
"""Get holders for a single token source."""
if source.chain in ["ethereum", "base", "bsc"]:
return await MultiChainSnapshotEngine._get_evm_holders(source)
elif source.chain == "solana":
return await MultiChainSnapshotEngine._get_solana_holders(source)
elif source.chain == "tron":
return await MultiChainSnapshotEngine._get_tron_holders(source)
else:
raise ValueError(f"Unsupported chain: {source.chain}")
@staticmethod
async def _get_evm_holders(source: TokenSource) -> dict[str, str]:
"""Get ERC-20 holders via RPC event logs."""
from web3 import Web3
rpc_url = os.getenv(f"{source.chain.upper()}_RPC_URL", "")
if not rpc_url:
raise ValueError(f"No RPC for {source.chain}")
w3 = Web3(Web3.HTTPProvider(rpc_url))
# Transfer event topic
transfer_topic = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
# Get recent blocks (last ~30 days worth, ~200k blocks on Base)
current_block = w3.eth.block_number
from_block = max(0, current_block - 200000)
try:
logs = w3.eth.get_logs(
{
"fromBlock": from_block,
"toBlock": current_block,
"address": source.token_address,
"topics": [transfer_topic],
}
)
except Exception as e:
logger.error(f"EVM log query failed: {e}")
return {}
# Build balances from transfer logs
balances: dict[str, int] = {}
for log in logs:
try:
from_addr = "0x" + log.topics[1].hex()[-40:]
to_addr = "0x" + log.topics[2].hex()[-40:]
amount = int(log.data.hex(), 16) if log.data else 0
balances[from_addr] = balances.get(from_addr, 0) - amount
balances[to_addr] = balances.get(to_addr, 0) + amount
except Exception:
continue
# Filter by minimum holdings
min_amount = int(source.min_holdings)
result = {}
for addr, balance in balances.items():
if balance > 0 and balance >= min_amount:
# Apply max cap if set
if source.max_holdings:
balance = min(balance, int(source.max_holdings))
result[addr] = str(balance)
return result
@staticmethod
async def _get_solana_holders(source: TokenSource) -> dict[str, str]:
"""Get SPL token holders via RPC."""
from solana.rpc.api import Client
from solders.pubkey import Pubkey
rpc_url = os.getenv("SOLANA_RPC_URL", "https://api.mainnet-beta.solana.com")
client = Client(rpc_url)
try:
mint = Pubkey.from_string(source.token_address)
response = client.get_token_accounts_by_mint_json_parsed(mint, commitment="confirmed")
holders = {}
min_amount = int(source.min_holdings)
for account in response["result"]["value"]:
try:
parsed = account["account"]["data"]["parsed"]["info"]
owner = parsed["owner"]
amount = int(parsed["tokenAmount"]["amount"])
if amount >= min_amount:
if source.max_holdings:
amount = min(amount, int(source.max_holdings))
holders[owner] = str(amount)
except Exception:
continue
return holders
except Exception as e:
logger.error(f"Solana holder fetch failed: {e}")
return {}
@staticmethod
async def _get_tron_holders(source: TokenSource) -> dict[str, str]:
"""Get TRC-20 holders via TronGrid."""
# TRON holder enumeration requires an indexer or TronGrid API
# Placeholder for now
logger.warning("TRON holder snapshot not yet implemented - use manual list or TronGrid API")
return {}
# ── Weighted Allocation Calculator ─────────────────────────────
class WeightedAirdropCalculator:
"""
Calculate airdrop allocations based on weighted holdings across chains.
"""
@staticmethod
def calculate_proportional_allocations(
holders: dict[str, CrossChainHolder],
sources: list[TokenSource],
total_pool: str,
require_both: bool = False,
require_any: bool = True,
cross_chain_bonus: float = 0.0,
) -> dict[str, CrossChainHolder]:
"""
Calculate proportional allocations.
Formula per holder:
weighted_score = sum(holding * weight_multiplier) for each source
If cross_chain_bonus > 0 and holder has tokens on multiple chains:
bonus = weighted_score * cross_chain_bonus * (num_chains - 1)
weighted_score += bonus
allocation = (weighted_score / total_all_scores) * total_pool
"""
total_pool_int = int(total_pool)
# Calculate weighted scores
total_score = 0.0
qualified = []
for addr, holder in holders.items():
score = 0.0
chains_present = set()
for source in sources:
source_key = f"{source.chain}:{source.token_symbol}"
if source_key in holder.holdings_per_source:
amount = float(holder.holdings_per_source[source_key])
score += amount * source.weight_multiplier
chains_present.add(source.chain)
# Apply cross-chain bonus
if cross_chain_bonus > 0 and len(chains_present) > 1:
bonus = score * cross_chain_bonus * (len(chains_present) - 1)
score += bonus
logger.debug(f"Cross-chain bonus for {addr}: +{bonus} ({len(chains_present)} chains)")
holder.weighted_score = score
# Check qualification rules
if require_both and len(chains_present) < len({s.chain for s in sources}):
continue # Didn't hold on all required chains
if require_any and len(chains_present) == 0:
continue # Didn't hold on any chain
if score > 0:
total_score += score
qualified.append(holder)
# Calculate allocations
if total_score == 0:
logger.warning("No qualified holders - total score is 0")
return {}
for holder in qualified:
ratio = holder.weighted_score / total_score
allocation = int(total_pool_int * ratio)
holder.airdrop_allocation = str(allocation)
logger.info(f"Proportional allocation: {len(qualified)} qualified, total score: {total_score}")
return {h.primary_address: h for h in qualified}
@staticmethod
def calculate_tiered_allocations(
holders: dict[str, CrossChainHolder],
tiers: list[AirdropTier],
) -> dict[str, CrossChainHolder]:
"""
Calculate tiered allocations.
Tiers example:
Tier 1: score >= 1000000, allocation = 50000 tokens
Tier 2: score >= 100000, allocation = 5000 tokens
Tier 3: score >= 10000, allocation = 500 tokens
"""
qualified = []
for _addr, holder in holders.items():
score = holder.weighted_score
# Find matching tier
for tier in sorted(tiers, key=lambda t: t.min_score, reverse=True):
if score >= tier.min_score:
if tier.max_score and score > tier.max_score:
continue
base = int(tier.base_allocation)
allocation = int(base * tier.bonus_multiplier)
holder.airdrop_allocation = str(allocation)
holder.total_tokens_qualified = 1
qualified.append(holder)
break
logger.info(f"Tiered allocation: {len(qualified)} qualified across {len(tiers)} tiers")
return {h.primary_address: h for h in qualified}
@staticmethod
def calculate_fixed_allocations(
holders: dict[str, CrossChainHolder],
fixed_amount: str,
) -> dict[str, CrossChainHolder]:
"""Every qualified holder gets the same fixed amount."""
for holder in holders.values():
holder.airdrop_allocation = fixed_amount
return holders
@staticmethod
def calculate_one_to_one_allocations(
holders: dict[str, CrossChainHolder],
) -> tuple[dict[str, CrossChainHolder], str]:
"""
Calculate 1:1 allocations.
Each holder receives exactly the sum of their holdings across all source tokens.
Example:
- Holder has 5000 CRM on Solana + 3000 CryptoRugMunch on Zora
- Allocation = 5000 + 3000 = 8000 new tokens
Returns:
(holders_dict, total_pool)
"""
total_pool = 0
for _addr, holder in holders.items():
total_allocation = 0
for _source_key, amount_str in holder.holdings_per_source.items():
amount = int(amount_str)
# 1:1 - exact same amount
total_allocation += amount
holder.airdrop_allocation = str(total_allocation)
holder.weighted_score = float(total_allocation)
total_pool += total_allocation
logger.info(f"1:1 allocation: {len(holders)} holders, total pool: {total_pool}")
return holders, str(total_pool)
# ── Anti-Gaming / Deduplication ───────────────────────────────
class AntiGamingFilter:
"""
Filter out sybils, bots, and gaming attempts.
"""
KNOWN_BOT_PATTERNS = [ # noqa: RUF012
"0x0000000000000000000000000000000000000000",
"0x000000000000000000000000000000000000dead",
]
@staticmethod
async def filter_holders(
holders: dict[str, CrossChainHolder],
exclude_contracts: bool = True,
exclude_known_bots: bool = True,
min_wallet_age_days: int | None = None,
min_tx_count: int | None = None,
) -> dict[str, CrossChainHolder]:
"""Apply all anti-gaming filters."""
filtered = {}
for addr, holder in holders.items():
# Skip known bots
if exclude_known_bots and addr.lower() in AntiGamingFilter.KNOWN_BOT_PATTERNS:
continue
# Skip contracts (would need bytecode check - simplified)
if exclude_contracts:
# In production, check if address has code
pass
# Skip young wallets
if min_wallet_age_days and holder.wallet_age_days is not None:
if holder.wallet_age_days < min_wallet_age_days:
continue
# Skip low-activity wallets
if min_tx_count and holder.tx_count_30d < min_tx_count:
continue
filtered[addr] = holder
logger.info(f"Anti-gaming filter: {len(holders)} -> {len(filtered)} holders")
return filtered
@staticmethod
async def detect_sybils(holders: dict[str, CrossChainHolder]) -> list[str]:
"""
Detect potential sybil wallets (same owner, multiple addresses).
Simple heuristics: similar balances, same first funding, etc.
"""
# In production, use clustering analysis
# For now, return empty list
return []
# ── Cross-Chain Address Linking ───────────────────────────────
class CrossChainIdentityResolver:
"""
Link the same user's addresses across chains.
Methods:
1. Self-reported linking (user signs message proving ownership)
2. Common funding source (same wallet funded both addresses)
3. ENS/SNS resolution
4. Platform binding (Twitter, Discord, etc.)
"""
@staticmethod
async def resolve_linked_addresses(
primary_address: str,
chain: str,
) -> dict[str, str]:
"""
Find linked addresses for a wallet.
Returns {chain: address} mapping.
"""
# Check Redis for stored linkages
try:
import redis.asyncio as redis_lib
r = redis_lib.Redis(
host=os.getenv("REDIS_HOST", "localhost"),
port=int(os.getenv("REDIS_PORT", "6379")),
password=os.getenv("REDIS_PASSWORD", ""),
decode_responses=True,
)
key = f"wallet_links:{primary_address}:{chain}"
data = await r.get(key)
if data:
return json.loads(data)
except Exception:
pass
return {}
@staticmethod
async def store_linkage(
primary_address: str,
primary_chain: str,
linked_chain: str,
linked_address: str,
proof_signature: str,
) -> bool:
"""Store verified cross-chain address linkage."""
try:
import redis.asyncio as redis_lib
r = redis_lib.Redis(
host=os.getenv("REDIS_HOST", "localhost"),
port=int(os.getenv("REDIS_PORT", "6379")),
password=os.getenv("REDIS_PASSWORD", ""),
decode_responses=True,
)
key = f"wallet_links:{primary_address}:{primary_chain}"
existing = {}
data = await r.get(key)
if data:
existing = json.loads(data)
existing[linked_chain] = linked_address
await r.set(key, json.dumps(existing))
# Also store reverse mapping
reverse_key = f"wallet_links:{linked_address}:{linked_chain}"
reverse_data = {primary_chain: primary_address}
await r.set(reverse_key, json.dumps(reverse_data))
return True
except Exception as e:
logger.error(f"Store linkage failed: {e}")
return False
# ── Custom Snapshot Loader ──────────────────────────────────
class CustomSnapshotLoader:
"""
Load custom snapshot data from uploaded files or manual input.
Supports JSON, CSV, and manual address lists.
"""
@staticmethod
def parse_json_snapshot(data: str) -> dict[str, CrossChainHolder]:
"""Parse JSON snapshot data."""
holders = {}
parsed = json.loads(data)
for entry in parsed:
addr = entry.get("address", entry.get("wallet", entry.get("holder", "")))
if not addr:
continue
amount = str(entry.get("amount", entry.get("balance", entry.get("holdings", "0"))))
chain = entry.get("chain", "unknown")
token = entry.get("token", entry.get("token_symbol", "UNKNOWN"))
if addr not in holders:
holders[addr] = CrossChainHolder(
primary_address=addr,
chain_addresses={chain: addr},
holdings_per_source={},
)
source_key = f"{chain}:{token}"
# Sum amounts if same address appears multiple times
if source_key in holders[addr].holdings_per_source:
existing = int(holders[addr].holdings_per_source[source_key])
holders[addr].holdings_per_source[source_key] = str(existing + int(amount))
else:
holders[addr].holdings_per_source[source_key] = amount
holders[addr].chain_addresses[chain] = addr
logger.info(f"Parsed JSON snapshot: {len(holders)} holders")
return holders
@staticmethod
def parse_csv_snapshot(data: str) -> dict[str, CrossChainHolder]:
"""Parse CSV snapshot data."""
import csv
import io
holders = {}
reader = csv.DictReader(io.StringIO(data))
for row in reader:
addr = row.get("address", row.get("wallet", row.get("holder", "")))
if not addr:
continue
amount = str(row.get("amount", row.get("balance", row.get("holdings", "0"))))
chain = row.get("chain", "unknown")
token = row.get("token", row.get("token_symbol", "UNKNOWN"))
if addr not in holders:
holders[addr] = CrossChainHolder(
primary_address=addr,
chain_addresses={chain: addr},
holdings_per_source={},
)
source_key = f"{chain}:{token}"
if source_key in holders[addr].holdings_per_source:
existing = int(holders[addr].holdings_per_source[source_key])
holders[addr].holdings_per_source[source_key] = str(existing + int(amount))
else:
holders[addr].holdings_per_source[source_key] = amount
holders[addr].chain_addresses[chain] = addr
logger.info(f"Parsed CSV snapshot: {len(holders)} holders")
return holders
@staticmethod
def parse_manual_holders(holders_list: list[dict[str, str]]) -> dict[str, CrossChainHolder]:
"""Parse manual holder list."""
holders = {}
for entry in holders_list:
addr = entry.get("address", "")
if not addr:
continue
amount = str(entry.get("amount", "0"))
chain = entry.get("chain", "unknown")
token = entry.get("token", "UNKNOWN")
if addr not in holders:
holders[addr] = CrossChainHolder(
primary_address=addr,
chain_addresses={chain: addr},
holdings_per_source={},
)
source_key = f"{chain}:{token}"
if source_key in holders[addr].holdings_per_source:
existing = int(holders[addr].holdings_per_source[source_key])
holders[addr].holdings_per_source[source_key] = str(existing + int(amount))
else:
holders[addr].holdings_per_source[source_key] = amount
holders[addr].chain_addresses[chain] = addr
logger.info(f"Parsed manual holders: {len(holders)} holders")
return holders
@staticmethod
def deduplicate_holders(holders: dict[str, CrossChainHolder]) -> dict[str, CrossChainHolder]:
"""Deduplicate by address (case-insensitive)."""
deduped = {}
for addr, holder in holders.items():
lower_addr = addr.lower()
if lower_addr in deduped:
# Merge holdings
for source_key, amount in holder.holdings_per_source.items():
if source_key in deduped[lower_addr].holdings_per_source:
existing = int(deduped[lower_addr].holdings_per_source[source_key])
deduped[lower_addr].holdings_per_source[source_key] = str(existing + int(amount))
else:
deduped[lower_addr].holdings_per_source[source_key] = amount
# Merge chain addresses
deduped[lower_addr].chain_addresses.update(holder.chain_addresses)
else:
deduped[lower_addr] = holder
logger.info(f"Deduplicated: {len(holders)} -> {len(deduped)} holders")
return deduped
# ── Campaign Manager ──────────────────────────────────────────
class MultiChainAirdropManager:
"""
Full lifecycle manager for multi-chain airdrop campaigns.
"""
@staticmethod
async def create_campaign(
target_chain: str,
target_token: str,
source_tokens: list[TokenSource],
total_pool: str,
distribution_mode: str = "proportional",
**kwargs,
) -> MultiChainAirdropCampaign:
"""Create a new campaign."""
campaign = MultiChainAirdropCampaign(
campaign_id=f"mc_airdrop_{target_chain}_{int(time.time())}",
target_chain=target_chain,
target_token=target_token,
source_tokens=source_tokens,
total_airdrop_pool=total_pool,
distribution_mode=distribution_mode,
**kwargs,
)
# Save to storage
await MultiChainAirdropManager._save_campaign(campaign)
return campaign
@staticmethod
async def execute_snapshot(campaign_id: str) -> MultiChainAirdropCampaign:
"""Execute snapshot phase for a campaign."""
campaign = await MultiChainAirdropManager._get_campaign(campaign_id)
if not campaign:
raise ValueError(f"Campaign not found: {campaign_id}")
# Create multi-chain snapshot
holders = await MultiChainSnapshotEngine.create_multichain_snapshot(
sources=campaign.source_tokens,
exclude_addresses=campaign.blacklist_preloaded if hasattr(campaign, "blacklist_preloaded") else [],
)
# Apply anti-gaming filters
holders = await AntiGamingFilter.filter_holders(
holders,
exclude_contracts=campaign.exclude_contracts,
exclude_known_bots=campaign.exclude_known_bots,
min_wallet_age_days=campaign.min_wallet_age_days,
min_tx_count=campaign.min_tx_count_30d,
)
# Calculate allocations
if campaign.distribution_mode == "proportional":
holders = WeightedAirdropCalculator.calculate_proportional_allocations(
holders,
campaign.source_tokens,
campaign.total_airdrop_pool,
require_both=campaign.require_hold_both_chains,
require_any=campaign.require_hold_any_chain,
cross_chain_bonus=campaign.cross_chain_bonus,
)
elif campaign.distribution_mode == "tiered":
holders = WeightedAirdropCalculator.calculate_tiered_allocations(
holders,
campaign.tiers,
)
elif campaign.distribution_mode == "fixed":
holders = WeightedAirdropCalculator.calculate_fixed_allocations(
holders,
campaign.total_airdrop_pool, # This would be per-holder fixed amount
)
elif campaign.distribution_mode == "one_to_one":
holders, total_pool = WeightedAirdropCalculator.calculate_one_to_one_allocations(
holders,
)
campaign.total_airdrop_pool = total_pool # Auto-set from snapshot
else:
raise ValueError(f"Unknown distribution mode: {campaign.distribution_mode}")
campaign.qualified_holders = list(holders.values())
campaign.total_qualified = len(holders)
campaign.snapshot_completed_at = datetime.utcnow().isoformat()
campaign.status = "snapshot_complete"
await MultiChainAirdropManager._save_campaign(campaign)
logger.info(f"Snapshot complete: {campaign.total_qualified} qualified for {campaign.campaign_id}")
return campaign
@staticmethod
async def execute_custom_snapshot(
campaign_id: str,
custom_holders: dict[str, CrossChainHolder],
deduplicate: bool = True,
) -> MultiChainAirdropCampaign:
"""Execute snapshot using custom/pre-uploaded holder data."""
campaign = await MultiChainAirdropManager._get_campaign(campaign_id)
if not campaign:
raise ValueError(f"Campaign not found: {campaign_id}")
holders = custom_holders
# Deduplicate if requested
if deduplicate:
holders = CustomSnapshotLoader.deduplicate_holders(holders)
# Apply anti-gaming filters
holders = await AntiGamingFilter.filter_holders(
holders,
exclude_contracts=campaign.exclude_contracts,
exclude_known_bots=campaign.exclude_known_bots,
min_wallet_age_days=campaign.min_wallet_age_days,
min_tx_count=campaign.min_tx_count_30d,
)
# Calculate allocations
if campaign.distribution_mode == "one_to_one":
holders, total_pool = WeightedAirdropCalculator.calculate_one_to_one_allocations(holders)
campaign.total_airdrop_pool = total_pool
elif campaign.distribution_mode == "proportional":
holders = WeightedAirdropCalculator.calculate_proportional_allocations(
holders,
campaign.source_tokens,
campaign.total_airdrop_pool,
require_both=campaign.require_hold_both_chains,
require_any=campaign.require_hold_any_chain,
cross_chain_bonus=campaign.cross_chain_bonus,
)
elif campaign.distribution_mode == "tiered":
holders = WeightedAirdropCalculator.calculate_tiered_allocations(holders, campaign.tiers)
elif campaign.distribution_mode == "fixed":
holders = WeightedAirdropCalculator.calculate_fixed_allocations(holders, campaign.total_airdrop_pool)
campaign.qualified_holders = list(holders.values())
campaign.total_qualified = len(holders)
campaign.snapshot_completed_at = datetime.utcnow().isoformat()
campaign.status = "snapshot_complete"
await MultiChainAirdropManager._save_campaign(campaign)
logger.info(f"Custom snapshot complete: {campaign.total_qualified} qualified for {campaign.campaign_id}")
return campaign
@staticmethod
async def execute_distribution(campaign_id: str) -> MultiChainAirdropCampaign:
"""Execute airdrop distribution."""
campaign = await MultiChainAirdropManager._get_campaign(campaign_id)
if not campaign:
raise ValueError(f"Campaign not found: {campaign_id}")
if campaign.status != "snapshot_complete":
raise ValueError(f"Campaign not ready for distribution: {campaign.status}")
# Get deployer for target chain
deployer = TokenDeployerFactory.get_deployer(campaign.target_chain) # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue
# Distribute to all qualified holders
total_distributed = 0
tx_hashes = []
for holder in campaign.qualified_holders:
if int(holder.airdrop_allocation) <= 0:
continue
try:
# Determine target address on target chain
target_address = holder.chain_addresses.get(campaign.target_chain, holder.primary_address)
tx = await deployer.mint_tokens(
campaign.target_token,
target_address,
holder.airdrop_allocation,
)
holder.claimed = True
holder.claim_tx = tx
total_distributed += int(holder.airdrop_allocation)
tx_hashes.append(tx)
logger.info(f"Airdropped {holder.airdrop_allocation} to {target_address}")
except Exception as e:
logger.error(f"Distribution failed for {holder.primary_address}: {e}")
campaign.total_distributed = str(total_distributed)
campaign.tx_hashes = tx_hashes
campaign.distribution_completed_at = datetime.utcnow().isoformat()
campaign.status = "completed" if len(tx_hashes) == len(campaign.qualified_holders) else "partial"
await MultiChainAirdropManager._save_campaign(campaign)
return campaign
@staticmethod
async def _save_campaign(campaign: MultiChainAirdropCampaign) -> bool:
"""Save campaign to storage."""
try:
from app.token_deployer import get_storage
storage = await get_storage()
if storage.redis:
key = f"multichain_airdrop:{campaign.campaign_id}"
await storage.redis.set(key, json.dumps(campaign.to_dict()))
await storage.redis.sadd("multichain_airdrops:all", campaign.campaign_id)
return True
except Exception as e:
logger.error(f"Save campaign failed: {e}")
return False
@staticmethod
async def _get_campaign(campaign_id: str) -> MultiChainAirdropCampaign | None:
"""Get campaign from storage."""
try:
from app.token_deployer import get_storage
storage = await get_storage()
if storage.redis:
data = await storage.redis.get(f"multichain_airdrop:{campaign_id}")
if data:
return MultiChainAirdropCampaign(**json.loads(data))
return None
except Exception as e:
logger.error(f"Get campaign failed: {e}")
return None
# ── Convenience: CRM + CryptoRugMunch Multi-Chain Airdrop ───
async def create_crm_multichain_airdrop(
crm_base_address: str,
crm_solana_address: str,
cryptorugmunch_solana_address: str,
target_chain: str = "solana",
target_token: str = "",
total_pool: str = "100000000",
) -> MultiChainAirdropCampaign:
"""
Create the standard CRM + CryptoRugMunch multi-chain airdrop.
Qualification:
- Hold CRM on Base (weight 2.0x)
- Hold CRM on Solana (weight 1.5x)
- Hold CryptoRugMunch on Solana (weight 1.0x)
- Hold ANY of the above = qualified
- Hold on multiple chains = cross-chain bonus 10%
- Minimum 1000 tokens on any chain
Distribution:
- Proportional to weighted holdings
- On target_chain (default Solana)
"""
sources = [
TokenSource(
chain="base",
token_address=crm_base_address,
token_symbol="CRM",
weight_multiplier=2.0,
min_holdings="1000",
),
TokenSource(
chain="solana",
token_address=crm_solana_address,
token_symbol="CRM",
weight_multiplier=1.5,
min_holdings="1000",
),
TokenSource(
chain="solana",
token_address=cryptorugmunch_solana_address,
token_symbol="CryptoRugMunch",
weight_multiplier=1.0,
min_holdings="1",
),
]
campaign = await MultiChainAirdropManager.create_campaign(
target_chain=target_chain,
target_token=target_token,
source_tokens=sources,
total_pool=total_pool,
distribution_mode="proportional",
require_hold_both_chains=False,
require_hold_any_chain=True,
cross_chain_bonus=0.10, # 10% bonus per additional chain
exclude_contracts=True,
exclude_known_bots=True,
min_wallet_age_days=7,
snapshot_time_window_hours=24,
)
return campaign
async def create_one_to_one_airdrop(
source_tokens: list[TokenSource],
target_chain: str,
target_token: str,
) -> MultiChainAirdropCampaign:
"""
Create a 1:1 airdrop campaign.
Each holder receives exactly the same amount they hold of the source token(s).
If they hold multiple qualifying tokens, they receive the SUM (1:1 for each).
Example:
- Hold 5000 CRM on Solana → receive 5000 new tokens
- Hold 3000 CryptoRugMunch on Zora → receive 3000 new tokens
- Hold both → receive 8000 new tokens
Args:
source_tokens: List of TokenSource configs (chain, address, symbol)
target_chain: Where to airdrop the new token
target_token: Contract address of the new token
"""
# Calculate total pool = sum of all holdings across all sources
# We snapshot first to know the total, then set pool = total supply
campaign = await MultiChainAirdropManager.create_campaign(
target_chain=target_chain,
target_token=target_token,
source_tokens=source_tokens,
total_pool="0", # Will be set after snapshot
distribution_mode="one_to_one", # Custom mode
require_hold_both_chains=False,
require_hold_any_chain=True,
cross_chain_bonus=0.0, # No bonus for 1:1 - it's already exact
exclude_contracts=True,
exclude_known_bots=True,
min_wallet_age_days=1,
snapshot_time_window_hours=24,
)
return campaign
async def create_crm_v2_airdrop_preset(
crm_solana_address: str,
cryptorugmunch_zora_address: str,
target_chain: str = "solana",
target_token: str = "",
) -> MultiChainAirdropCampaign:
"""
CRM v2 1:1 Airdrop Preset.
Qualification:
- Hold $CRM on Solana → receive exact same amount of CRMv2
- Hold CryptoRugMunch on Zora → receive exact same amount of CRMv2
- Hold both → receive SUM of both holdings
Total pool auto-calculated from snapshot.
"""
sources = [
TokenSource(
chain="solana",
token_address=crm_solana_address,
token_symbol="CRM",
weight_multiplier=1.0, # 1:1
min_holdings="1",
),
TokenSource(
chain="base", # Zora is on Base
token_address=cryptorugmunch_zora_address,
token_symbol="CryptoRugMunch",
weight_multiplier=1.0, # 1:1
min_holdings="1",
),
]
campaign = await create_one_to_one_airdrop(
source_tokens=sources,
target_chain=target_chain,
target_token=target_token,
)
return campaign