alphatrack 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- alphatrack/__init__.py +3 -0
- alphatrack/__main__.py +4 -0
- alphatrack/adapters/__init__.py +3 -0
- alphatrack/adapters/base.py +62 -0
- alphatrack/adapters/opensea/__init__.py +3 -0
- alphatrack/adapters/opensea/auth.py +128 -0
- alphatrack/adapters/opensea/checker.py +158 -0
- alphatrack/adapters/opensea/client.py +101 -0
- alphatrack/cli.py +204 -0
- alphatrack/core/__init__.py +9 -0
- alphatrack/core/balance.py +57 -0
- alphatrack/core/config.py +10 -0
- alphatrack/core/models.py +35 -0
- alphatrack/core/report.py +135 -0
- alphatrack/core/user_config.py +110 -0
- alphatrack/core/wallets.py +48 -0
- alphatrack-0.1.0.dist-info/METADATA +114 -0
- alphatrack-0.1.0.dist-info/RECORD +22 -0
- alphatrack-0.1.0.dist-info/WHEEL +5 -0
- alphatrack-0.1.0.dist-info/entry_points.txt +2 -0
- alphatrack-0.1.0.dist-info/licenses/LICENSE +21 -0
- alphatrack-0.1.0.dist-info/top_level.txt +1 -0
alphatrack/__init__.py
ADDED
alphatrack/__main__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Dict, List
|
|
5
|
+
|
|
6
|
+
from alphatrack.core.models import EligibilityResult, Wallet
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class EligibilityAdapter(ABC):
|
|
10
|
+
"""Platform-specific eligibility checker."""
|
|
11
|
+
|
|
12
|
+
name: str
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def normalize_collection(self, collection_ref: str) -> str:
|
|
16
|
+
"""Turn a URL or slug into the canonical collection id for this platform."""
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def check_wallet(self, wallet: Wallet, collection: str) -> EligibilityResult:
|
|
20
|
+
"""Check a single wallet against one collection."""
|
|
21
|
+
|
|
22
|
+
def check_wallets(
|
|
23
|
+
self,
|
|
24
|
+
wallets: List[Wallet],
|
|
25
|
+
collection: str,
|
|
26
|
+
) -> Dict[str, EligibilityResult]:
|
|
27
|
+
"""Default sequential check — adapters may override for concurrency."""
|
|
28
|
+
results: Dict[str, EligibilityResult] = {}
|
|
29
|
+
slug = self.normalize_collection(collection)
|
|
30
|
+
for wallet in wallets:
|
|
31
|
+
results[wallet.address] = self.check_wallet(wallet, slug)
|
|
32
|
+
return results
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_REGISTRY: Dict[str, EligibilityAdapter] = {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def register_adapter(adapter: EligibilityAdapter) -> None:
|
|
39
|
+
_REGISTRY[adapter.name] = adapter
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_adapter(platform: str) -> EligibilityAdapter:
|
|
43
|
+
key = platform.lower().strip()
|
|
44
|
+
if key not in _REGISTRY:
|
|
45
|
+
# Lazy import built-in adapters
|
|
46
|
+
_ensure_builtins()
|
|
47
|
+
if key not in _REGISTRY:
|
|
48
|
+
available = ", ".join(sorted(_REGISTRY)) or "(none)"
|
|
49
|
+
raise ValueError(f"Unknown platform '{platform}'. Available: {available}")
|
|
50
|
+
return _REGISTRY[key]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def list_platforms() -> List[str]:
|
|
54
|
+
_ensure_builtins()
|
|
55
|
+
return sorted(_REGISTRY)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _ensure_builtins() -> None:
|
|
59
|
+
if "opensea" not in _REGISTRY:
|
|
60
|
+
from alphatrack.adapters.opensea import OpenSeaAdapter
|
|
61
|
+
|
|
62
|
+
register_adapter(OpenSeaAdapter())
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
from eth_account import Account
|
|
9
|
+
from eth_account.messages import encode_defunct
|
|
10
|
+
|
|
11
|
+
# Limit concurrent SIWE logins to avoid 429s
|
|
12
|
+
_auth_semaphore = threading.Semaphore(2)
|
|
13
|
+
|
|
14
|
+
NONCE_URL = "https://opensea.io/__api/auth/siwe/nonce"
|
|
15
|
+
VERIFY_URL = "https://opensea.io/__api/auth/siwe/verify"
|
|
16
|
+
|
|
17
|
+
STATEMENT = (
|
|
18
|
+
"Click to sign in and accept the OpenSea Terms of Service "
|
|
19
|
+
"(https://opensea.io/tos) and Privacy Policy "
|
|
20
|
+
"(https://opensea.io/privacy)."
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
BASE_HEADERS = {
|
|
24
|
+
"User-Agent": (
|
|
25
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
26
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
27
|
+
"Chrome/148.0.0.0 Safari/537.36"
|
|
28
|
+
),
|
|
29
|
+
"Origin": "https://opensea.io",
|
|
30
|
+
"Referer": "https://opensea.io/",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _post_with_retry(session: requests.Session, url: str, **kwargs):
|
|
35
|
+
for attempt in range(5):
|
|
36
|
+
r = session.post(url, **kwargs)
|
|
37
|
+
if r.status_code == 429:
|
|
38
|
+
time.sleep(2**attempt)
|
|
39
|
+
continue
|
|
40
|
+
r.raise_for_status()
|
|
41
|
+
return r
|
|
42
|
+
r.raise_for_status()
|
|
43
|
+
return r
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _get_nonce(session: requests.Session) -> str:
|
|
47
|
+
r = _post_with_retry(
|
|
48
|
+
session,
|
|
49
|
+
NONCE_URL,
|
|
50
|
+
headers={**BASE_HEADERS, "Content-Type": "application/json"},
|
|
51
|
+
)
|
|
52
|
+
return r.json()["nonce"]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _build_message(address: str, nonce: str, uri: str = "https://opensea.io") -> dict:
|
|
56
|
+
issued_at = datetime.datetime.utcnow().isoformat(timespec="milliseconds") + "Z"
|
|
57
|
+
return {
|
|
58
|
+
"domain": "opensea.io",
|
|
59
|
+
"address": address,
|
|
60
|
+
"statement": STATEMENT,
|
|
61
|
+
"uri": uri,
|
|
62
|
+
"version": "1",
|
|
63
|
+
"chainId": "1",
|
|
64
|
+
"nonce": nonce,
|
|
65
|
+
"issuedAt": issued_at,
|
|
66
|
+
"accountType": "Ethereum",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _format_siwe_text(msg: dict) -> str:
|
|
71
|
+
return (
|
|
72
|
+
f"{msg['domain']} wants you to sign in with your Ethereum account:\n"
|
|
73
|
+
f"{msg['address']}\n\n"
|
|
74
|
+
f"{msg['statement']}\n\n"
|
|
75
|
+
f"URI: {msg['uri']}\n"
|
|
76
|
+
f"Version: {msg['version']}\n"
|
|
77
|
+
f"Chain ID: {msg['chainId']}\n"
|
|
78
|
+
f"Nonce: {msg['nonce']}\n"
|
|
79
|
+
f"Issued At: {msg['issuedAt']}"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _siwe_login(session: requests.Session, address: str, private_key: str) -> str:
|
|
84
|
+
nonce = _get_nonce(session)
|
|
85
|
+
message = _build_message(address, nonce)
|
|
86
|
+
text = _format_siwe_text(message)
|
|
87
|
+
signed = Account.sign_message(encode_defunct(text=text), private_key=private_key)
|
|
88
|
+
|
|
89
|
+
payload = {
|
|
90
|
+
"message": message,
|
|
91
|
+
"signature": signed.signature.hex(),
|
|
92
|
+
"chainArch": "EVM",
|
|
93
|
+
"connectorId": "io.metamask",
|
|
94
|
+
}
|
|
95
|
+
r = _post_with_retry(
|
|
96
|
+
session,
|
|
97
|
+
VERIFY_URL,
|
|
98
|
+
json=payload,
|
|
99
|
+
headers={**BASE_HEADERS, "Content-Type": "application/json"},
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
access_token = r.cookies.get("access_token")
|
|
103
|
+
if not access_token:
|
|
104
|
+
raise ValueError("access_token not returned by OpenSea verify endpoint")
|
|
105
|
+
return access_token
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def login(session: requests.Session, address: str, private_key: str) -> str:
|
|
109
|
+
"""
|
|
110
|
+
SIWE login against OpenSea. Sets session cookies used by GraphQL.
|
|
111
|
+
|
|
112
|
+
Private keys and signatures stay on this machine — only OpenSea auth
|
|
113
|
+
endpoints are contacted.
|
|
114
|
+
"""
|
|
115
|
+
with _auth_semaphore:
|
|
116
|
+
access_token = _siwe_login(session, address, private_key)
|
|
117
|
+
|
|
118
|
+
for name in ("access_token", "auth_hint", "auth_access_hint"):
|
|
119
|
+
val = session.cookies.get(name)
|
|
120
|
+
if val:
|
|
121
|
+
session.cookies.set(name, val, domain="gql.opensea.io", path="/")
|
|
122
|
+
|
|
123
|
+
# Value used by OpenSea web client
|
|
124
|
+
os2 = "Wdr6yywSUvHzIWYeBybn17MOdxKRbbhM"
|
|
125
|
+
session.cookies.set("os2AccessEx", os2, domain="opensea.io", path="/")
|
|
126
|
+
session.cookies.set("os2AccessEx", os2, domain="gql.opensea.io", path="/")
|
|
127
|
+
|
|
128
|
+
return access_token
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
6
|
+
from typing import Dict, List, Optional
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
from tqdm import tqdm
|
|
11
|
+
|
|
12
|
+
from alphatrack.adapters.base import EligibilityAdapter
|
|
13
|
+
from alphatrack.adapters.opensea import auth, client
|
|
14
|
+
from alphatrack.core.config import SUBMIT_STAGGER_SEC, WORKERS
|
|
15
|
+
from alphatrack.core.models import EligibilityResult, StageResult, Wallet
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_collection_slug(collection_ref: str) -> str:
|
|
21
|
+
"""Accept a slug or full OpenSea collection URL."""
|
|
22
|
+
ref = collection_ref.strip().rstrip("/")
|
|
23
|
+
if "opensea.io" in ref or ref.startswith("http"):
|
|
24
|
+
path = urlparse(ref).path
|
|
25
|
+
parts = [p for p in path.split("/") if p]
|
|
26
|
+
if "collection" in parts:
|
|
27
|
+
idx = parts.index("collection")
|
|
28
|
+
if idx + 1 < len(parts):
|
|
29
|
+
return parts[idx + 1]
|
|
30
|
+
raise ValueError(f"Could not parse collection slug from URL: {collection_ref}")
|
|
31
|
+
return ref
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _price_from_stage(stage: dict) -> tuple[Optional[float], str]:
|
|
35
|
+
price_info = stage.get("eligiblePrice") or {}
|
|
36
|
+
token = price_info.get("token") or {}
|
|
37
|
+
unit = token.get("unit")
|
|
38
|
+
symbol = token.get("symbol") or "ETH"
|
|
39
|
+
if unit is None:
|
|
40
|
+
return None, symbol
|
|
41
|
+
return float(unit), symbol
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _to_stage_results(
|
|
45
|
+
stages: List[dict],
|
|
46
|
+
schedule_by_idx: Dict[int, dict],
|
|
47
|
+
) -> List[StageResult]:
|
|
48
|
+
label_map = {
|
|
49
|
+
"SIGNED_PRESALE": "Presale",
|
|
50
|
+
"PUBLIC_SALE": "Public",
|
|
51
|
+
}
|
|
52
|
+
results: List[StageResult] = []
|
|
53
|
+
for stage in stages:
|
|
54
|
+
if not isinstance(stage, dict):
|
|
55
|
+
continue
|
|
56
|
+
idx = stage.get("stageIndex", 0)
|
|
57
|
+
sched = schedule_by_idx.get(idx, {})
|
|
58
|
+
price_eth, symbol = _price_from_stage(stage)
|
|
59
|
+
stage_type = stage.get("stageType", "STAGE")
|
|
60
|
+
api_label = (sched.get("label") or "").strip()
|
|
61
|
+
short = api_label or label_map.get(stage_type, stage_type)
|
|
62
|
+
if stage_type == "SIGNED_PRESALE" and not api_label:
|
|
63
|
+
short = f"{short} {idx}".strip()
|
|
64
|
+
|
|
65
|
+
results.append(
|
|
66
|
+
StageResult(
|
|
67
|
+
stage_index=idx,
|
|
68
|
+
stage_type=stage_type,
|
|
69
|
+
label=short,
|
|
70
|
+
is_eligible=bool(stage.get("isEligible")),
|
|
71
|
+
max_mint=stage.get("eligibleMaxTotalMintableByWallet"),
|
|
72
|
+
price_eth=price_eth,
|
|
73
|
+
price_symbol=symbol,
|
|
74
|
+
start_time=sched.get("startTime"),
|
|
75
|
+
end_time=sched.get("endTime"),
|
|
76
|
+
raw=stage,
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
return results
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class OpenSeaAdapter(EligibilityAdapter):
|
|
83
|
+
name = "opensea"
|
|
84
|
+
|
|
85
|
+
def normalize_collection(self, collection_ref: str) -> str:
|
|
86
|
+
return parse_collection_slug(collection_ref)
|
|
87
|
+
|
|
88
|
+
def check_wallet(self, wallet: Wallet, collection: str) -> EligibilityResult:
|
|
89
|
+
slug = self.normalize_collection(collection)
|
|
90
|
+
try:
|
|
91
|
+
session = requests.Session()
|
|
92
|
+
auth.login(session, wallet.address, wallet.private_key)
|
|
93
|
+
schedule = client.get_drop_schedule(slug)
|
|
94
|
+
schedule_by_idx = {s.get("stageIndex"): s for s in schedule}
|
|
95
|
+
stages = client.get_eligibility(session, wallet.address, slug)
|
|
96
|
+
return EligibilityResult(
|
|
97
|
+
address=wallet.address,
|
|
98
|
+
platform=self.name,
|
|
99
|
+
collection=slug,
|
|
100
|
+
stages=_to_stage_results(stages, schedule_by_idx),
|
|
101
|
+
)
|
|
102
|
+
except Exception as e:
|
|
103
|
+
return EligibilityResult(
|
|
104
|
+
address=wallet.address,
|
|
105
|
+
platform=self.name,
|
|
106
|
+
collection=slug,
|
|
107
|
+
error=str(e),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def check_wallets(
|
|
111
|
+
self,
|
|
112
|
+
wallets: List[Wallet],
|
|
113
|
+
collection: str,
|
|
114
|
+
) -> Dict[str, EligibilityResult]:
|
|
115
|
+
slug = self.normalize_collection(collection)
|
|
116
|
+
# Prefetch schedule once (shared across wallets)
|
|
117
|
+
try:
|
|
118
|
+
schedule = client.get_drop_schedule(slug)
|
|
119
|
+
except Exception as e:
|
|
120
|
+
log.warning("Failed to fetch drop schedule: %s", e)
|
|
121
|
+
schedule = []
|
|
122
|
+
schedule_by_idx = {s.get("stageIndex"): s for s in schedule}
|
|
123
|
+
|
|
124
|
+
results: Dict[str, EligibilityResult] = {}
|
|
125
|
+
futures = {}
|
|
126
|
+
|
|
127
|
+
def _one(wallet: Wallet) -> EligibilityResult:
|
|
128
|
+
try:
|
|
129
|
+
session = requests.Session()
|
|
130
|
+
auth.login(session, wallet.address, wallet.private_key)
|
|
131
|
+
stages = client.get_eligibility(session, wallet.address, slug)
|
|
132
|
+
return EligibilityResult(
|
|
133
|
+
address=wallet.address,
|
|
134
|
+
platform=self.name,
|
|
135
|
+
collection=slug,
|
|
136
|
+
stages=_to_stage_results(stages, schedule_by_idx),
|
|
137
|
+
)
|
|
138
|
+
except Exception as e:
|
|
139
|
+
return EligibilityResult(
|
|
140
|
+
address=wallet.address,
|
|
141
|
+
platform=self.name,
|
|
142
|
+
collection=slug,
|
|
143
|
+
error=str(e),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
with ThreadPoolExecutor(max_workers=WORKERS) as executor:
|
|
147
|
+
for wallet in wallets:
|
|
148
|
+
future = executor.submit(_one, wallet)
|
|
149
|
+
futures[future] = wallet.address
|
|
150
|
+
time.sleep(SUBMIT_STAGGER_SEC)
|
|
151
|
+
|
|
152
|
+
with tqdm(total=len(wallets), desc=slug, unit="wallet") as pbar:
|
|
153
|
+
for future in as_completed(futures):
|
|
154
|
+
addr = futures[future]
|
|
155
|
+
results[addr] = future.result()
|
|
156
|
+
pbar.update(1)
|
|
157
|
+
|
|
158
|
+
return results
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, List
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
GQL_URL = "https://gql.opensea.io/graphql"
|
|
8
|
+
|
|
9
|
+
BASE_HEADERS = {
|
|
10
|
+
"User-Agent": (
|
|
11
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
12
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
13
|
+
"Chrome/148.0.0.0 Safari/537.36"
|
|
14
|
+
),
|
|
15
|
+
"accept": (
|
|
16
|
+
"application/graphql-response+json, application/graphql+json, "
|
|
17
|
+
"application/json, text/event-stream, multipart/mixed"
|
|
18
|
+
),
|
|
19
|
+
"accept-language": "en-US,en;q=0.9",
|
|
20
|
+
"content-type": "application/json",
|
|
21
|
+
"Origin": "https://opensea.io",
|
|
22
|
+
"Referer": "https://opensea.io/",
|
|
23
|
+
"x-app-id": "os2-web",
|
|
24
|
+
"x-graphql-operation-type": "query",
|
|
25
|
+
"sec-ch-ua": '"Chromium";v="148", "Google Chrome";v="148", "Not/A)Brand";v="99"',
|
|
26
|
+
"sec-ch-ua-mobile": "?0",
|
|
27
|
+
"sec-ch-ua-platform": '"macOS"',
|
|
28
|
+
"Sec-Fetch-Dest": "empty",
|
|
29
|
+
"Sec-Fetch-Mode": "cors",
|
|
30
|
+
"Sec-Fetch-Site": "same-site",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
ELIGIBILITY_QUERY = {
|
|
34
|
+
"extensions": {
|
|
35
|
+
"persistedQuery": {
|
|
36
|
+
"sha256Hash": (
|
|
37
|
+
"d893f026d731e8f14986921fa4229098e018289f6cc7683f8ee2dd83749dd95d"
|
|
38
|
+
),
|
|
39
|
+
"version": 1,
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"operationName": "DropEligibilityQuery",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
DROP_SCHEDULE_QUERY = """
|
|
46
|
+
query DropStages($slug: String!) {
|
|
47
|
+
dropBySlug(slug: $slug) {
|
|
48
|
+
__typename
|
|
49
|
+
... on Drop {
|
|
50
|
+
stages {
|
|
51
|
+
label
|
|
52
|
+
stageType
|
|
53
|
+
stageIndex
|
|
54
|
+
startTime
|
|
55
|
+
endTime
|
|
56
|
+
maxTotalMintableByWallet
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_drop_schedule(slug: str) -> List[dict]:
|
|
65
|
+
"""Fetch mint schedule (start/end times). No auth required."""
|
|
66
|
+
payload = {
|
|
67
|
+
"query": DROP_SCHEDULE_QUERY,
|
|
68
|
+
"variables": {"slug": slug},
|
|
69
|
+
}
|
|
70
|
+
response = requests.post(GQL_URL, json=payload, headers=BASE_HEADERS, timeout=30)
|
|
71
|
+
data = response.json()
|
|
72
|
+
return data.get("data", {}).get("dropBySlug", {}).get("stages", []) or []
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_eligibility(session: requests.Session, address: str, slug: str) -> List[dict]:
|
|
76
|
+
"""
|
|
77
|
+
Query drop eligibility for a wallet.
|
|
78
|
+
|
|
79
|
+
The connected-account-server-hint cookie must match the wallet address
|
|
80
|
+
being queried — OpenSea uses it to resolve isEligible.
|
|
81
|
+
"""
|
|
82
|
+
addr_lower = address.lower()
|
|
83
|
+
session.cookies.set(
|
|
84
|
+
"connected-account-server-hint", addr_lower, domain="gql.opensea.io", path="/"
|
|
85
|
+
)
|
|
86
|
+
session.cookies.set(
|
|
87
|
+
"connected-account-server-hint", addr_lower, domain="opensea.io", path="/"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
payload: dict[str, Any] = {
|
|
91
|
+
**ELIGIBILITY_QUERY,
|
|
92
|
+
"variables": {
|
|
93
|
+
"address": addr_lower,
|
|
94
|
+
"collectionSlug": slug,
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
response = session.post(GQL_URL, json=payload, headers=BASE_HEADERS, timeout=30)
|
|
99
|
+
data = response.json()
|
|
100
|
+
stages = data.get("data", {}).get("dropBySlug", {}).get("stages", [])
|
|
101
|
+
return stages if stages else []
|
alphatrack/cli.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from alphatrack import __version__
|
|
10
|
+
from alphatrack.adapters import get_adapter, list_platforms
|
|
11
|
+
from alphatrack.core.balance import fetch_eth_balances
|
|
12
|
+
from alphatrack.core.report import print_summary
|
|
13
|
+
from alphatrack.core.user_config import load_user_config, resolve_check_options
|
|
14
|
+
from alphatrack.core.wallets import load_wallets
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
18
|
+
parser = argparse.ArgumentParser(
|
|
19
|
+
prog="alphatrack",
|
|
20
|
+
description=(
|
|
21
|
+
"AlphaTrack open-source toolkit — check NFT drop eligibility locally. "
|
|
22
|
+
"Private keys never leave your machine."
|
|
23
|
+
),
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
26
|
+
|
|
27
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
28
|
+
|
|
29
|
+
sub.add_parser("platforms", help="List supported platforms")
|
|
30
|
+
|
|
31
|
+
check = sub.add_parser(
|
|
32
|
+
"check",
|
|
33
|
+
help="Check wallet eligibility for one or more drops",
|
|
34
|
+
description=(
|
|
35
|
+
"Check eligibility from config.yaml (supports multiple platforms via "
|
|
36
|
+
"`checks:`) and/or -c flags. Example: alphatrack check"
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
check.add_argument(
|
|
40
|
+
"-f",
|
|
41
|
+
"--config",
|
|
42
|
+
default=None,
|
|
43
|
+
help="Path to YAML config (default: ./config.yaml if present)",
|
|
44
|
+
)
|
|
45
|
+
check.add_argument(
|
|
46
|
+
"-c",
|
|
47
|
+
"--collection",
|
|
48
|
+
action="append",
|
|
49
|
+
default=None,
|
|
50
|
+
dest="collections",
|
|
51
|
+
help="Collection slug or URL (repeatable; overrides config jobs if set)",
|
|
52
|
+
)
|
|
53
|
+
check.add_argument(
|
|
54
|
+
"-p",
|
|
55
|
+
"--platform",
|
|
56
|
+
default=None,
|
|
57
|
+
help="Platform adapter (with -c, or forces one platform over config jobs)",
|
|
58
|
+
)
|
|
59
|
+
check.add_argument(
|
|
60
|
+
"-w",
|
|
61
|
+
"--wallets",
|
|
62
|
+
default=None,
|
|
63
|
+
help="Path to wallets file (default: from config, else wallets.txt)",
|
|
64
|
+
)
|
|
65
|
+
check.add_argument(
|
|
66
|
+
"--balances",
|
|
67
|
+
action="store_true",
|
|
68
|
+
help="Fetch ETH balances for eligible wallets (needs ALCHEMY_API_KEY)",
|
|
69
|
+
)
|
|
70
|
+
check.add_argument(
|
|
71
|
+
"-v",
|
|
72
|
+
"--verbose",
|
|
73
|
+
action="store_true",
|
|
74
|
+
help="Verbose logging",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
return parser
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _cmd_platforms() -> int:
|
|
81
|
+
for name in list_platforms():
|
|
82
|
+
print(name)
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _collect_eligible_addresses(results) -> list[str]:
|
|
87
|
+
eligible = set()
|
|
88
|
+
for result in results.values():
|
|
89
|
+
for stage in result.stages:
|
|
90
|
+
if stage.is_eligible and stage.stage_type != "PUBLIC_SALE":
|
|
91
|
+
eligible.add(result.address)
|
|
92
|
+
break
|
|
93
|
+
return list(eligible)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _run_collection(adapter, wallets, collection_ref, balances_enabled, log):
|
|
97
|
+
slug = adapter.normalize_collection(collection_ref)
|
|
98
|
+
col_start = time.time()
|
|
99
|
+
results = adapter.check_wallets(wallets, slug)
|
|
100
|
+
|
|
101
|
+
balances = None
|
|
102
|
+
if balances_enabled:
|
|
103
|
+
eligible_addrs = _collect_eligible_addresses(results)
|
|
104
|
+
if eligible_addrs:
|
|
105
|
+
if not os.getenv("ALCHEMY_API_KEY"):
|
|
106
|
+
log.warning("ALCHEMY_API_KEY missing — skipping balances")
|
|
107
|
+
else:
|
|
108
|
+
log.info(
|
|
109
|
+
"Fetching ETH balances for %d eligible wallet(s)...",
|
|
110
|
+
len(eligible_addrs),
|
|
111
|
+
)
|
|
112
|
+
balances = fetch_eth_balances(eligible_addrs)
|
|
113
|
+
|
|
114
|
+
print_summary(
|
|
115
|
+
wallets,
|
|
116
|
+
results,
|
|
117
|
+
collection=slug,
|
|
118
|
+
platform=adapter.name,
|
|
119
|
+
balances=balances,
|
|
120
|
+
)
|
|
121
|
+
log.info("Collection finished in %.1fs", time.time() - col_start)
|
|
122
|
+
return slug
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _cmd_check(args: argparse.Namespace) -> int:
|
|
126
|
+
logging.basicConfig(
|
|
127
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
128
|
+
format="[%(asctime)s] %(message)s",
|
|
129
|
+
datefmt="%H:%M:%S",
|
|
130
|
+
stream=sys.stdout,
|
|
131
|
+
)
|
|
132
|
+
log = logging.getLogger("alphatrack")
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
config = load_user_config(args.config)
|
|
136
|
+
except (FileNotFoundError, ValueError) as e:
|
|
137
|
+
log.error("%s", e)
|
|
138
|
+
return 1
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
opts = resolve_check_options(args, config)
|
|
142
|
+
except ValueError as e:
|
|
143
|
+
log.error("%s", e)
|
|
144
|
+
return 1
|
|
145
|
+
|
|
146
|
+
jobs = opts["jobs"]
|
|
147
|
+
if not jobs:
|
|
148
|
+
log.error(
|
|
149
|
+
"No collections specified. Add a `checks:` block (or legacy "
|
|
150
|
+
"`collections:`) in config.yaml, or pass -c/--collection."
|
|
151
|
+
)
|
|
152
|
+
return 1
|
|
153
|
+
|
|
154
|
+
if args.config:
|
|
155
|
+
log.info("Using config: %s", args.config)
|
|
156
|
+
elif config:
|
|
157
|
+
log.info("Using config: config.yaml")
|
|
158
|
+
|
|
159
|
+
wallets = load_wallets(opts["wallets"])
|
|
160
|
+
log.info("Loaded %d wallet(s) from %s", len(wallets), opts["wallets"])
|
|
161
|
+
|
|
162
|
+
total_collections = sum(len(j["collections"]) for j in jobs)
|
|
163
|
+
log.info(
|
|
164
|
+
"Jobs: %d platform block(s), %d collection(s) total",
|
|
165
|
+
len(jobs),
|
|
166
|
+
total_collections,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
total_start = time.time()
|
|
170
|
+
done = 0
|
|
171
|
+
for job in jobs:
|
|
172
|
+
platform = job["platform"]
|
|
173
|
+
try:
|
|
174
|
+
adapter = get_adapter(platform)
|
|
175
|
+
except ValueError as e:
|
|
176
|
+
log.error("%s", e)
|
|
177
|
+
return 1
|
|
178
|
+
|
|
179
|
+
log.info("Platform: %s (%d collection(s))", adapter.name, len(job["collections"]))
|
|
180
|
+
for collection_ref in job["collections"]:
|
|
181
|
+
done += 1
|
|
182
|
+
slug = adapter.normalize_collection(collection_ref)
|
|
183
|
+
log.info("[%d/%d] Checking %s/%s", done, total_collections, adapter.name, slug)
|
|
184
|
+
_run_collection(adapter, wallets, collection_ref, opts["balances"], log)
|
|
185
|
+
|
|
186
|
+
log.info("Done in %.1fs", time.time() - total_start)
|
|
187
|
+
return 0
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def main(argv: list[str] | None = None) -> int:
|
|
191
|
+
parser = _build_parser()
|
|
192
|
+
args = parser.parse_args(argv)
|
|
193
|
+
|
|
194
|
+
if args.command == "platforms":
|
|
195
|
+
return _cmd_platforms()
|
|
196
|
+
if args.command == "check":
|
|
197
|
+
return _cmd_check(args)
|
|
198
|
+
|
|
199
|
+
parser.print_help()
|
|
200
|
+
return 1
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
if __name__ == "__main__":
|
|
204
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from decimal import Decimal, getcontext
|
|
5
|
+
from typing import Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
from dotenv import load_dotenv
|
|
9
|
+
|
|
10
|
+
getcontext().prec = 28
|
|
11
|
+
load_dotenv()
|
|
12
|
+
|
|
13
|
+
ALCHEMY_NETWORK = "eth-mainnet"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _hex_to_eth(balance_hex: str) -> float:
|
|
17
|
+
raw = int(balance_hex or "0x0", 16)
|
|
18
|
+
return float(Decimal(raw) / Decimal(10**18))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def fetch_eth_balance(address: str, api_key: Optional[str] = None) -> float:
|
|
22
|
+
"""Fetch native ETH balance for a single wallet via Alchemy."""
|
|
23
|
+
api_key = api_key or os.getenv("ALCHEMY_API_KEY")
|
|
24
|
+
if not api_key:
|
|
25
|
+
raise ValueError("ALCHEMY_API_KEY not set (required for balance checks)")
|
|
26
|
+
|
|
27
|
+
endpoint = f"https://api.g.alchemy.com/data/v1/{api_key}/assets/tokens/by-address"
|
|
28
|
+
payload = {
|
|
29
|
+
"addresses": [{"address": address.lower(), "networks": [ALCHEMY_NETWORK]}],
|
|
30
|
+
"omitMetadata": True,
|
|
31
|
+
"pageSize": 200,
|
|
32
|
+
"includeNativeBalances": True,
|
|
33
|
+
}
|
|
34
|
+
response = requests.post(endpoint, json=payload, timeout=30)
|
|
35
|
+
if not response.ok:
|
|
36
|
+
detail = response.text[:200]
|
|
37
|
+
raise requests.HTTPError(
|
|
38
|
+
f"{response.status_code} from Alchemy: {detail}",
|
|
39
|
+
response=response,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
for token in response.json().get("data", {}).get("tokens") or []:
|
|
43
|
+
if token.get("tokenAddress") is None:
|
|
44
|
+
return _hex_to_eth(token.get("tokenBalance"))
|
|
45
|
+
return 0.0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def fetch_eth_balances(
|
|
49
|
+
addresses: List[str],
|
|
50
|
+
api_key: Optional[str] = None,
|
|
51
|
+
) -> Dict[str, float]:
|
|
52
|
+
"""Fetch native ETH balances one wallet at a time."""
|
|
53
|
+
balances: Dict[str, float] = {}
|
|
54
|
+
for addr in addresses:
|
|
55
|
+
normalized = addr.lower()
|
|
56
|
+
balances[normalized] = fetch_eth_balance(normalized, api_key=api_key)
|
|
57
|
+
return balances
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from dotenv import load_dotenv
|
|
4
|
+
|
|
5
|
+
load_dotenv()
|
|
6
|
+
|
|
7
|
+
# Keep low to avoid 429s on marketplace auth endpoints
|
|
8
|
+
WORKERS = int(os.getenv("ALPHATRACK_WORKERS", "2"))
|
|
9
|
+
GAS_BUFFER_ETH = float(os.getenv("ALPHATRACK_GAS_BUFFER_ETH", "0.005"))
|
|
10
|
+
SUBMIT_STAGGER_SEC = float(os.getenv("ALPHATRACK_SUBMIT_STAGGER_SEC", "0.8"))
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Wallet:
|
|
9
|
+
private_key: str
|
|
10
|
+
address: str
|
|
11
|
+
group: Optional[str] = None
|
|
12
|
+
label: Optional[str] = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class StageResult:
|
|
17
|
+
stage_index: int
|
|
18
|
+
stage_type: str
|
|
19
|
+
label: str
|
|
20
|
+
is_eligible: bool
|
|
21
|
+
max_mint: Optional[int] = None
|
|
22
|
+
price_eth: Optional[float] = None
|
|
23
|
+
price_symbol: str = "ETH"
|
|
24
|
+
start_time: Optional[str] = None
|
|
25
|
+
end_time: Optional[str] = None
|
|
26
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class EligibilityResult:
|
|
31
|
+
address: str
|
|
32
|
+
platform: str
|
|
33
|
+
collection: str
|
|
34
|
+
stages: list[StageResult] = field(default_factory=list)
|
|
35
|
+
error: Optional[str] = None
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
from alphatrack.core.config import GAS_BUFFER_ETH
|
|
7
|
+
from alphatrack.core.models import EligibilityResult, StageResult, Wallet
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _format_mint_time(start: Optional[str], end: Optional[str]) -> str:
|
|
11
|
+
if not start:
|
|
12
|
+
return ""
|
|
13
|
+
start_dt = datetime.fromisoformat(start.replace("Z", "+00:00"))
|
|
14
|
+
if not end:
|
|
15
|
+
return start_dt.strftime("%b %d %H:%M UTC")
|
|
16
|
+
end_dt = datetime.fromisoformat(end.replace("Z", "+00:00"))
|
|
17
|
+
if start_dt.date() == end_dt.date():
|
|
18
|
+
return f"{start_dt.strftime('%b %d %H:%M')}–{end_dt.strftime('%H:%M UTC')}"
|
|
19
|
+
return f"{start_dt.strftime('%b %d %H:%M')} → {end_dt.strftime('%b %d %H:%M UTC')}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _required_eth(price: Optional[float], max_mint: Optional[int]) -> Optional[float]:
|
|
23
|
+
if price is None or max_mint is None:
|
|
24
|
+
return None
|
|
25
|
+
return price * max_mint + GAS_BUFFER_ETH
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _format_balance_status(balance: float, required: Optional[float]) -> str:
|
|
29
|
+
if required is None:
|
|
30
|
+
return f"{balance:.4g} ETH"
|
|
31
|
+
if balance >= required:
|
|
32
|
+
return f"{balance:.4g} ETH ✓"
|
|
33
|
+
return f"{balance:.4g} ETH ✗ need ≥{required:.4g} ETH"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _stage_heading(stage: StageResult) -> str:
|
|
37
|
+
price = (
|
|
38
|
+
f"{stage.price_eth:.4g} {stage.price_symbol}"
|
|
39
|
+
if stage.price_eth is not None
|
|
40
|
+
else "?"
|
|
41
|
+
)
|
|
42
|
+
label = stage.label or stage.stage_type
|
|
43
|
+
return f"{label} ({price})"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def print_summary(
|
|
47
|
+
wallets: List[Wallet],
|
|
48
|
+
results: Dict[str, EligibilityResult],
|
|
49
|
+
collection: str,
|
|
50
|
+
platform: str = "opensea",
|
|
51
|
+
balances: Optional[Dict[str, float]] = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
sep = "=" * 60
|
|
54
|
+
print(f"\n{sep}")
|
|
55
|
+
print(f"PLATFORM: {platform}")
|
|
56
|
+
print(f"COLLECTION: {collection}")
|
|
57
|
+
print(f"{sep}")
|
|
58
|
+
|
|
59
|
+
# Pick a non-empty stage list as the template for ordering
|
|
60
|
+
all_stages: List[StageResult] = []
|
|
61
|
+
for result in results.values():
|
|
62
|
+
if result.stages:
|
|
63
|
+
type_order = {"SIGNED_PRESALE": 0, "PUBLIC_SALE": 1}
|
|
64
|
+
all_stages = sorted(
|
|
65
|
+
result.stages,
|
|
66
|
+
key=lambda s: (type_order.get(s.stage_type, 99), s.stage_index),
|
|
67
|
+
)
|
|
68
|
+
break
|
|
69
|
+
|
|
70
|
+
if not all_stages:
|
|
71
|
+
print(" No stage data returned.")
|
|
72
|
+
print(sep)
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
addr_to_group = {w.address: w.group for w in wallets}
|
|
76
|
+
eligible_by_stage: Dict[int, list] = {s.stage_index: [] for s in all_stages}
|
|
77
|
+
|
|
78
|
+
for wallet in wallets:
|
|
79
|
+
addr = wallet.address
|
|
80
|
+
result = results.get(addr)
|
|
81
|
+
if not result or result.error:
|
|
82
|
+
continue
|
|
83
|
+
by_idx = {s.stage_index: s for s in result.stages}
|
|
84
|
+
for stage in all_stages:
|
|
85
|
+
s = by_idx.get(stage.stage_index)
|
|
86
|
+
if s and s.is_eligible:
|
|
87
|
+
eligible_by_stage[stage.stage_index].append(
|
|
88
|
+
(addr, s.max_mint, addr_to_group.get(addr), s)
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
for stage in all_stages:
|
|
92
|
+
if stage.stage_type == "PUBLIC_SALE":
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
eligible = eligible_by_stage[stage.stage_index]
|
|
96
|
+
time_str = _format_mint_time(stage.start_time, stage.end_time)
|
|
97
|
+
time_part = f" — {time_str}" if time_str else ""
|
|
98
|
+
|
|
99
|
+
funded = 0
|
|
100
|
+
if balances and stage.price_eth is not None:
|
|
101
|
+
for addr, max_mint, _, _ in eligible:
|
|
102
|
+
required = _required_eth(stage.price_eth, max_mint)
|
|
103
|
+
if required is not None and balances.get(addr.lower(), 0) >= required:
|
|
104
|
+
funded += 1
|
|
105
|
+
|
|
106
|
+
funded_part = ""
|
|
107
|
+
if balances and stage.price_eth is not None and eligible:
|
|
108
|
+
funded_part = f" — {funded}/{len(eligible)} can afford mint"
|
|
109
|
+
|
|
110
|
+
print(
|
|
111
|
+
f"\n {_stage_heading(stage)}{time_part} — "
|
|
112
|
+
f"{len(eligible)} eligible wallet(s){funded_part}"
|
|
113
|
+
)
|
|
114
|
+
print(" " + "-" * 40)
|
|
115
|
+
|
|
116
|
+
if not eligible:
|
|
117
|
+
print(" (none)")
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
for addr, max_mint, group, _ in eligible:
|
|
121
|
+
group_str = f" [{group}]" if group else ""
|
|
122
|
+
bal_str = ""
|
|
123
|
+
if balances is not None:
|
|
124
|
+
bal = balances.get(addr.lower(), 0.0)
|
|
125
|
+
required = _required_eth(stage.price_eth, max_mint)
|
|
126
|
+
bal_str = f" {_format_balance_status(bal, required)}"
|
|
127
|
+
print(f" {addr}{group_str} (max {max_mint}){bal_str}")
|
|
128
|
+
|
|
129
|
+
errors = [(a, r.error) for a, r in results.items() if r.error]
|
|
130
|
+
if errors:
|
|
131
|
+
print(f"\n Errors ({len(errors)}):")
|
|
132
|
+
for addr, err in errors:
|
|
133
|
+
print(f" {addr[:12]}... — {err}")
|
|
134
|
+
|
|
135
|
+
print(f"\n{sep}")
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
DEFAULT_CONFIG_PATHS = ("config.yaml", "config.yml")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_user_config(path: str | Path | None = None) -> dict[str, Any]:
|
|
12
|
+
"""
|
|
13
|
+
Load a local YAML config.
|
|
14
|
+
|
|
15
|
+
If path is None, tries ./config.yaml then ./config.yml.
|
|
16
|
+
Returns {} when no config file is found.
|
|
17
|
+
"""
|
|
18
|
+
if path is not None:
|
|
19
|
+
file_path = Path(path)
|
|
20
|
+
if not file_path.exists():
|
|
21
|
+
raise FileNotFoundError(f"Config file not found: {file_path}")
|
|
22
|
+
return _read_yaml(file_path)
|
|
23
|
+
|
|
24
|
+
for candidate in DEFAULT_CONFIG_PATHS:
|
|
25
|
+
file_path = Path(candidate)
|
|
26
|
+
if file_path.exists():
|
|
27
|
+
return _read_yaml(file_path)
|
|
28
|
+
return {}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _read_yaml(path: Path) -> dict[str, Any]:
|
|
32
|
+
data = yaml.safe_load(path.read_text()) or {}
|
|
33
|
+
if not isinstance(data, dict):
|
|
34
|
+
raise ValueError(f"Config root must be a mapping: {path}")
|
|
35
|
+
return data
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _normalize_collections(raw: Any) -> list[str]:
|
|
39
|
+
if raw is None:
|
|
40
|
+
return []
|
|
41
|
+
if isinstance(raw, str):
|
|
42
|
+
return [raw.strip()] if raw.strip() else []
|
|
43
|
+
return [str(c).strip() for c in raw if str(c).strip()]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _jobs_from_checks_block(raw_checks: Any, default_platform: str) -> list[dict[str, Any]]:
|
|
47
|
+
if not isinstance(raw_checks, list):
|
|
48
|
+
raise ValueError("`checks` must be a list of platform blocks")
|
|
49
|
+
|
|
50
|
+
jobs: list[dict[str, Any]] = []
|
|
51
|
+
for i, block in enumerate(raw_checks):
|
|
52
|
+
if not isinstance(block, dict):
|
|
53
|
+
raise ValueError(f"`checks[{i}]` must be a mapping with platform/collections")
|
|
54
|
+
platform = str(block.get("platform") or default_platform)
|
|
55
|
+
collections = _normalize_collections(block.get("collections"))
|
|
56
|
+
if not collections:
|
|
57
|
+
raise ValueError(f"`checks[{i}]` has no collections")
|
|
58
|
+
jobs.append({"platform": platform, "collections": collections})
|
|
59
|
+
return jobs
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _jobs_from_config(cfg: dict[str, Any]) -> list[dict[str, Any]]:
|
|
63
|
+
default_platform = str(cfg.get("platform") or "opensea")
|
|
64
|
+
|
|
65
|
+
if cfg.get("checks") is not None:
|
|
66
|
+
return _jobs_from_checks_block(cfg.get("checks"), default_platform)
|
|
67
|
+
|
|
68
|
+
# Legacy / simple shape: top-level platform + collections
|
|
69
|
+
collections = _normalize_collections(cfg.get("collections"))
|
|
70
|
+
if collections:
|
|
71
|
+
return [{"platform": default_platform, "collections": collections}]
|
|
72
|
+
return []
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def resolve_check_options(args: Any, config: Optional[dict[str, Any]] = None) -> dict[str, Any]:
|
|
76
|
+
"""
|
|
77
|
+
Merge CLI over config over built-in defaults.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
wallets, balances, jobs: [{platform, collections}, ...]
|
|
81
|
+
"""
|
|
82
|
+
cfg = config or {}
|
|
83
|
+
default_platform = str(args.platform or cfg.get("platform") or "opensea")
|
|
84
|
+
wallets = args.wallets or cfg.get("wallets") or "wallets.txt"
|
|
85
|
+
|
|
86
|
+
if args.balances:
|
|
87
|
+
balances = True
|
|
88
|
+
elif "balances" in cfg:
|
|
89
|
+
balances = bool(cfg["balances"])
|
|
90
|
+
else:
|
|
91
|
+
balances = False
|
|
92
|
+
|
|
93
|
+
# CLI -c wins: single job (optionally with -p)
|
|
94
|
+
cli_collections = _normalize_collections(args.collections)
|
|
95
|
+
if cli_collections:
|
|
96
|
+
jobs = [{"platform": default_platform, "collections": cli_collections}]
|
|
97
|
+
else:
|
|
98
|
+
jobs = _jobs_from_config(cfg)
|
|
99
|
+
# If user passed -p with checks/legacy config, force that platform on all jobs
|
|
100
|
+
if args.platform:
|
|
101
|
+
jobs = [
|
|
102
|
+
{"platform": str(args.platform), "collections": j["collections"]}
|
|
103
|
+
for j in jobs
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
"wallets": str(wallets),
|
|
108
|
+
"balances": balances,
|
|
109
|
+
"jobs": jobs,
|
|
110
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from eth_account import Account
|
|
6
|
+
|
|
7
|
+
from alphatrack.core.models import Wallet
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_wallets(path: str | Path = "wallets.txt") -> list[Wallet]:
|
|
11
|
+
"""
|
|
12
|
+
Load wallets from a text file.
|
|
13
|
+
|
|
14
|
+
- One private key per line
|
|
15
|
+
- Blank lines ignored
|
|
16
|
+
- Lines starting with # are optional group labels for following keys
|
|
17
|
+
"""
|
|
18
|
+
file_path = Path(path)
|
|
19
|
+
if not file_path.exists():
|
|
20
|
+
raise FileNotFoundError(
|
|
21
|
+
f"Wallet file not found: {file_path}. "
|
|
22
|
+
"Copy wallets.example.txt to wallets.txt and add burner keys."
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
wallets: list[Wallet] = []
|
|
26
|
+
current_group: str | None = None
|
|
27
|
+
|
|
28
|
+
for line in file_path.read_text().splitlines():
|
|
29
|
+
raw = line.strip()
|
|
30
|
+
if not raw:
|
|
31
|
+
continue
|
|
32
|
+
if raw.startswith("#"):
|
|
33
|
+
current_group = raw.lstrip("#").strip() or None
|
|
34
|
+
continue
|
|
35
|
+
|
|
36
|
+
account = Account.from_key(raw)
|
|
37
|
+
wallets.append(
|
|
38
|
+
Wallet(
|
|
39
|
+
private_key=raw,
|
|
40
|
+
address=account.address,
|
|
41
|
+
group=current_group,
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if not wallets:
|
|
46
|
+
raise ValueError(f"No private keys found in {file_path}")
|
|
47
|
+
|
|
48
|
+
return wallets
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: alphatrack
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AlphaTrack open-source local toolkit — NFT drop eligibility checkers (keys stay on your machine)
|
|
5
|
+
Author: AlphaTrack
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://alphatrack.xyz/
|
|
8
|
+
Project-URL: Documentation, https://github.com/alphatrack/alphatrack
|
|
9
|
+
Keywords: alphatrack,opensea,eligibility,nft,presale
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: requests>=2.28.0
|
|
20
|
+
Requires-Dist: eth-account>=0.10.0
|
|
21
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
22
|
+
Requires-Dist: tqdm>=4.66.0
|
|
23
|
+
Requires-Dist: PyYAML>=6.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: build; extra == "dev"
|
|
26
|
+
Requires-Dist: twine; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# AlphaTrack
|
|
30
|
+
|
|
31
|
+
Open-source **local** toolkit for NFT drop eligibility checks.
|
|
32
|
+
|
|
33
|
+
Private keys stay on your machine. This repo is separate from the hosted AlphaTrack platform.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
# from PyPI (once published)
|
|
39
|
+
pip install alphatrack
|
|
40
|
+
|
|
41
|
+
# or from this folder
|
|
42
|
+
pip install -e .
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Quick start
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
cp wallets.example.txt wallets.txt
|
|
49
|
+
# add burner private keys to wallets.txt
|
|
50
|
+
|
|
51
|
+
cp config.example.yaml config.yaml
|
|
52
|
+
# edit `checks:` — one block per platform, many collections each
|
|
53
|
+
|
|
54
|
+
cp .env.example .env
|
|
55
|
+
# optional: set ALCHEMY_API_KEY for balance checks
|
|
56
|
+
|
|
57
|
+
alphatrack platforms
|
|
58
|
+
|
|
59
|
+
# run every platform/collection block in config.yaml
|
|
60
|
+
alphatrack check
|
|
61
|
+
|
|
62
|
+
# or pass collections on the CLI (single platform per invocation)
|
|
63
|
+
alphatrack check -p opensea -c your-collection-slug -c another-slug
|
|
64
|
+
alphatrack check -c https://opensea.io/collection/your-collection/overview --balances
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Example `config.yaml` with multiple platforms:
|
|
68
|
+
|
|
69
|
+
```yaml
|
|
70
|
+
wallets: wallets.txt
|
|
71
|
+
balances: false
|
|
72
|
+
checks:
|
|
73
|
+
- platform: opensea
|
|
74
|
+
collections:
|
|
75
|
+
- deadmood-42226579
|
|
76
|
+
- the-gh0sts
|
|
77
|
+
- platform: launchmynft # when adapter is available
|
|
78
|
+
collections:
|
|
79
|
+
- some-lmnft-slug
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Or without installing the script entrypoint:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
python -m alphatrack check
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## What this does
|
|
89
|
+
|
|
90
|
+
1. Loads wallets from a local file
|
|
91
|
+
2. Signs in to the marketplace locally (OpenSea SIWE today)
|
|
92
|
+
3. Reports which wallets are eligible for which mint stages
|
|
93
|
+
4. Optionally checks whether eligible wallets can afford the mint (Alchemy)
|
|
94
|
+
|
|
95
|
+
It does **not** mint, submit transactions, or upload private keys to AlphaTrack servers.
|
|
96
|
+
|
|
97
|
+
## Supported platforms
|
|
98
|
+
|
|
99
|
+
| Platform | Status |
|
|
100
|
+
|----------|--------|
|
|
101
|
+
| OpenSea | ✅ v0.1 |
|
|
102
|
+
| LaunchMyNFT | planned |
|
|
103
|
+
| Transient | planned |
|
|
104
|
+
|
|
105
|
+
## Security
|
|
106
|
+
|
|
107
|
+
- Use **burner wallets** when possible
|
|
108
|
+
- Never commit `wallets.txt` or `.env`
|
|
109
|
+
- Network calls go to the marketplace (and optionally Alchemy) — not to AlphaTrack
|
|
110
|
+
- v1 only signs SIWE login messages, not mint transactions
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
MIT
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
alphatrack/__init__.py,sha256=wCg1JuPhnnHsoarYPpFnud_aWtoLvGuWDsJCS_2eRpI,87
|
|
2
|
+
alphatrack/__main__.py,sha256=e_v2QFfP_9REcQy2f87yFyQuhuU9TG1_LS5ftlZ8B18,89
|
|
3
|
+
alphatrack/cli.py,sha256=tWNcwsYbazNkXxJFJnEh2SN3QtOWB3BvzSxx0crXkx8,5934
|
|
4
|
+
alphatrack/adapters/__init__.py,sha256=NHMNE5J_xyNfQ_dfdzrPiFBxyk0cQD7QrYDCRrFWu20,152
|
|
5
|
+
alphatrack/adapters/base.py,sha256=ABeaGL6O1BB18yrWDbZEQylUhRefvVGXtjM45MjPe98,1835
|
|
6
|
+
alphatrack/adapters/opensea/__init__.py,sha256=p9ht6b5P7X-4dTkaBS_BEiLg8H4UdGc09PnOe8-ca7w,93
|
|
7
|
+
alphatrack/adapters/opensea/auth.py,sha256=1m1k8NVAlTcINsdCwpXjWOmyGTlqV-QZ29dbsLV6jB0,3799
|
|
8
|
+
alphatrack/adapters/opensea/checker.py,sha256=-VPFtMYjV0KL4WOhna-721XMaFCOKBzUDuuhvjIR1c8,5632
|
|
9
|
+
alphatrack/adapters/opensea/client.py,sha256=YIpQiin_f4zmDo8R186q7m9UScWA0H7Sa8PhZMg0cYE,2869
|
|
10
|
+
alphatrack/core/__init__.py,sha256=5Ptw1iJ-O7aSknITPU7bfxOxa9jdgKbneNzSC13iBmQ,216
|
|
11
|
+
alphatrack/core/balance.py,sha256=aiSQj7pF5sPZkW6PVdKQFtLfOAVoVnP84U3xJIrvChc,1787
|
|
12
|
+
alphatrack/core/config.py,sha256=_k_Si4cO_Xf9L0nj5JVZ2QlqkVTbkRL9fMd_Dh_XjiY,315
|
|
13
|
+
alphatrack/core/models.py,sha256=JRtEYXa4E1rWUIDNoo2g6MNPcMNik7CCvBzI9D2lsMA,763
|
|
14
|
+
alphatrack/core/report.py,sha256=iqfDYPsGnrTPNBL5vHkF_TYzd1Xmv7RvtwzJx08AQMo,4636
|
|
15
|
+
alphatrack/core/user_config.py,sha256=Dl1WKh4qcT_6Jrwht5XwLUr1CyilCgpAaFOUXv-ft-M,3615
|
|
16
|
+
alphatrack/core/wallets.py,sha256=j1TFkuGh8Nih_BHE206CU1E3BSmZzTZfceqpYmWRlX0,1247
|
|
17
|
+
alphatrack-0.1.0.dist-info/licenses/LICENSE,sha256=YOOCHjpsNjO6RXVOaYeuWLAkKks65W5Eh1EZtbDOS1Q,1067
|
|
18
|
+
alphatrack-0.1.0.dist-info/METADATA,sha256=l7w23SBxdtIc9jGdurKmPE_2L5Ur76r6_YW_4o5Pxz8,2994
|
|
19
|
+
alphatrack-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
20
|
+
alphatrack-0.1.0.dist-info/entry_points.txt,sha256=eXkfw7dLAhw70bwKDLI-E3x4_9kWIUlVktiMeYKM5fI,51
|
|
21
|
+
alphatrack-0.1.0.dist-info/top_level.txt,sha256=2NhSOYi2MUXVpwSD7pM2G_cLkCN8_uEV10X8wBMBgrU,11
|
|
22
|
+
alphatrack-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AlphaTrack
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
alphatrack
|