alphatrack 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- alphatrack-0.1.0/PKG-INFO +114 -0
- alphatrack-0.1.0/README.md +86 -0
- alphatrack-0.1.0/pyproject.toml +41 -0
- alphatrack-0.1.0/setup.cfg +4 -0
- alphatrack-0.1.0/src/alphatrack/__init__.py +3 -0
- alphatrack-0.1.0/src/alphatrack/__main__.py +4 -0
- alphatrack-0.1.0/src/alphatrack/adapters/__init__.py +3 -0
- alphatrack-0.1.0/src/alphatrack/adapters/base.py +62 -0
- alphatrack-0.1.0/src/alphatrack/adapters/opensea/__init__.py +3 -0
- alphatrack-0.1.0/src/alphatrack/adapters/opensea/auth.py +128 -0
- alphatrack-0.1.0/src/alphatrack/adapters/opensea/checker.py +158 -0
- alphatrack-0.1.0/src/alphatrack/adapters/opensea/client.py +101 -0
- alphatrack-0.1.0/src/alphatrack/cli.py +204 -0
- alphatrack-0.1.0/src/alphatrack/core/__init__.py +9 -0
- alphatrack-0.1.0/src/alphatrack/core/balance.py +57 -0
- alphatrack-0.1.0/src/alphatrack/core/config.py +10 -0
- alphatrack-0.1.0/src/alphatrack/core/models.py +35 -0
- alphatrack-0.1.0/src/alphatrack/core/report.py +135 -0
- alphatrack-0.1.0/src/alphatrack/core/user_config.py +110 -0
- alphatrack-0.1.0/src/alphatrack/core/wallets.py +48 -0
- alphatrack-0.1.0/src/alphatrack.egg-info/PKG-INFO +114 -0
- alphatrack-0.1.0/src/alphatrack.egg-info/SOURCES.txt +25 -0
- alphatrack-0.1.0/src/alphatrack.egg-info/dependency_links.txt +1 -0
- alphatrack-0.1.0/src/alphatrack.egg-info/entry_points.txt +2 -0
- alphatrack-0.1.0/src/alphatrack.egg-info/requires.txt +9 -0
- alphatrack-0.1.0/src/alphatrack.egg-info/top_level.txt +1 -0
alphatrack-0.1.0/LICENSE
ADDED
|
@@ -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,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,86 @@
|
|
|
1
|
+
# AlphaTrack
|
|
2
|
+
|
|
3
|
+
Open-source **local** toolkit for NFT drop eligibility checks.
|
|
4
|
+
|
|
5
|
+
Private keys stay on your machine. This repo is separate from the hosted AlphaTrack platform.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# from PyPI (once published)
|
|
11
|
+
pip install alphatrack
|
|
12
|
+
|
|
13
|
+
# or from this folder
|
|
14
|
+
pip install -e .
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
cp wallets.example.txt wallets.txt
|
|
21
|
+
# add burner private keys to wallets.txt
|
|
22
|
+
|
|
23
|
+
cp config.example.yaml config.yaml
|
|
24
|
+
# edit `checks:` — one block per platform, many collections each
|
|
25
|
+
|
|
26
|
+
cp .env.example .env
|
|
27
|
+
# optional: set ALCHEMY_API_KEY for balance checks
|
|
28
|
+
|
|
29
|
+
alphatrack platforms
|
|
30
|
+
|
|
31
|
+
# run every platform/collection block in config.yaml
|
|
32
|
+
alphatrack check
|
|
33
|
+
|
|
34
|
+
# or pass collections on the CLI (single platform per invocation)
|
|
35
|
+
alphatrack check -p opensea -c your-collection-slug -c another-slug
|
|
36
|
+
alphatrack check -c https://opensea.io/collection/your-collection/overview --balances
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Example `config.yaml` with multiple platforms:
|
|
40
|
+
|
|
41
|
+
```yaml
|
|
42
|
+
wallets: wallets.txt
|
|
43
|
+
balances: false
|
|
44
|
+
checks:
|
|
45
|
+
- platform: opensea
|
|
46
|
+
collections:
|
|
47
|
+
- deadmood-42226579
|
|
48
|
+
- the-gh0sts
|
|
49
|
+
- platform: launchmynft # when adapter is available
|
|
50
|
+
collections:
|
|
51
|
+
- some-lmnft-slug
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Or without installing the script entrypoint:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
python -m alphatrack check
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## What this does
|
|
61
|
+
|
|
62
|
+
1. Loads wallets from a local file
|
|
63
|
+
2. Signs in to the marketplace locally (OpenSea SIWE today)
|
|
64
|
+
3. Reports which wallets are eligible for which mint stages
|
|
65
|
+
4. Optionally checks whether eligible wallets can afford the mint (Alchemy)
|
|
66
|
+
|
|
67
|
+
It does **not** mint, submit transactions, or upload private keys to AlphaTrack servers.
|
|
68
|
+
|
|
69
|
+
## Supported platforms
|
|
70
|
+
|
|
71
|
+
| Platform | Status |
|
|
72
|
+
|----------|--------|
|
|
73
|
+
| OpenSea | ✅ v0.1 |
|
|
74
|
+
| LaunchMyNFT | planned |
|
|
75
|
+
| Transient | planned |
|
|
76
|
+
|
|
77
|
+
## Security
|
|
78
|
+
|
|
79
|
+
- Use **burner wallets** when possible
|
|
80
|
+
- Never commit `wallets.txt` or `.env`
|
|
81
|
+
- Network calls go to the marketplace (and optionally Alchemy) — not to AlphaTrack
|
|
82
|
+
- v1 only signs SIWE login messages, not mint transactions
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "alphatrack"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "AlphaTrack open-source local toolkit — NFT drop eligibility checkers (keys stay on your machine)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "AlphaTrack" }]
|
|
13
|
+
keywords = ["alphatrack", "opensea", "eligibility", "nft", "presale"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Software Development :: Libraries",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"requests>=2.28.0",
|
|
24
|
+
"eth-account>=0.10.0",
|
|
25
|
+
"python-dotenv>=1.0.0",
|
|
26
|
+
"tqdm>=4.66.0",
|
|
27
|
+
"PyYAML>=6.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = ["build", "twine"]
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
alphatrack = "alphatrack.cli:main"
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://alphatrack.xyz/"
|
|
38
|
+
Documentation = "https://github.com/alphatrack/alphatrack"
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.packages.find]
|
|
41
|
+
where = ["src"]
|
|
@@ -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
|