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 ADDED
@@ -0,0 +1,3 @@
1
+ """AlphaTrack — open-source local NFT eligibility toolkit."""
2
+
3
+ __version__ = "0.1.0"
alphatrack/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from alphatrack.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,3 @@
1
+ from alphatrack.adapters.base import EligibilityAdapter, get_adapter, list_platforms
2
+
3
+ __all__ = ["EligibilityAdapter", "get_adapter", "list_platforms"]
@@ -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,3 @@
1
+ from alphatrack.adapters.opensea.checker import OpenSeaAdapter
2
+
3
+ __all__ = ["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,9 @@
1
+ from alphatrack.core.models import EligibilityResult, StageResult, Wallet
2
+ from alphatrack.core.wallets import load_wallets
3
+
4
+ __all__ = [
5
+ "EligibilityResult",
6
+ "StageResult",
7
+ "Wallet",
8
+ "load_wallets",
9
+ ]
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ alphatrack = alphatrack.cli:main
@@ -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