├── .gitignore ├── README.md ├── main.py ├── mintbot ├── __init__.py ├── block_listener.py ├── contract_verifier.py ├── eth_contract_service.py ├── storage.py └── types.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # free mint bot 2 | 3 | 一个 demo 级别的 free mint 机器人,参考打狗神器 [mycointool](https://mycointool.com/nft/minting) 4 | 5 | ## 依赖 6 | 1. redis(存储模块使用) 7 | 8 | 使用前请在本机安装 redis,如需使用远程 redis,自己修改 ``mintbot/storage.py`` 中的 redis 初始化配置 9 | 10 | 11 | ## 运行 12 | ```shell 13 | # 推荐使用 venv 管理, 在 main.py 中写入自己的 API_KEY 以后 14 | pip isntall -r requirements.txt 15 | python main.py 16 | ``` 17 | 18 | ## TODO 19 | 1. 性能优化,可行方向有: 20 | * 在合约创建后就开始进行 verify 相关操作,并且将结果写入 redis,这样可以节省处理最新 block 时的性能开销 21 | * 每个 block 都并发处理,防止阻塞对新块的处理 22 | * 储备 API_KEY 池,避免 etherscan 等外部依赖的限速问题 23 | 2. 配置化 24 | 3. 前端展示 25 | 4. 优雅退出 -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from loguru import logger 2 | from mintbot import BlockListener 3 | from mintbot import ContractVerifier 4 | from mintbot import backend 5 | import time 6 | from web3 import Web3 7 | 8 | 9 | def main(): 10 | ETHERSCAN_API_KEY = "" 11 | OPENSEA_API_KEY = "" 12 | ETH_PROVIDER_URL = "" 13 | w3 = Web3(Web3.HTTPProvider(ETH_PROVIDER_URL)) 14 | contract_verifier = ContractVerifier(ETHERSCAN_API_KEY, OPENSEA_API_KEY, w3) 15 | block_listener = BlockListener(w3, contract_verifier) 16 | block_listener.start() 17 | 18 | while True: 19 | block_mints, time_mints = backend.get_mint_stats() 20 | for block_number in sorted(block_mints.keys()): 21 | for mint in block_mints[block_number]: 22 | logger.info(f"{block_number} {mint}") 23 | 24 | for k, v in time_mints.items(): 25 | for info in v: 26 | logger.info(f"{k} {info}") 27 | time.sleep(3) 28 | 29 | block_listener.join() 30 | 31 | 32 | if __name__ == '__main__': 33 | main() 34 | -------------------------------------------------------------------------------- /mintbot/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | A simple mint rebot inspired by mycointool(mainly for study) 3 | """ 4 | 5 | from .block_listener import BlockListener as BlockListener 6 | from .contract_verifier import ContractVerifier 7 | from .types import * 8 | from .storage import backend 9 | 10 | __version__ = "0.0.1" 11 | -------------------------------------------------------------------------------- /mintbot/block_listener.py: -------------------------------------------------------------------------------- 1 | from threading import Thread 2 | from web3 import Web3 3 | from web3.types import TxData, TxReceipt, LogReceipt 4 | from .eth_contract_service import EthContractService 5 | from .contract_verifier import ContractVerifier 6 | from .storage import backend 7 | from .types import MintInfo 8 | from loguru import logger 9 | 10 | 11 | class BlockListener(Thread): 12 | 13 | def __init__(self, w3: Web3, verifier: ContractVerifier): 14 | super().__init__() 15 | self._w3 = w3 16 | self._contract_service = EthContractService() 17 | self._verifier = verifier 18 | 19 | def parse_mint_info(self, receipt: TxReceipt, tx: TxData) -> MintInfo: 20 | mint_num = 0 21 | mint_to = False 22 | 23 | input = tx['input'] 24 | logs = receipt['logs'] 25 | for log in logs: 26 | # 注意并不一定所有 log 都对应了 transfer 27 | if not self._is_mint_log(log): 28 | continue 29 | 30 | mint_num += 1 31 | if self._w3.toBytes(hexstr=tx['from']) not in log['topics'][2]: 32 | mint_to = True 33 | 34 | if mint_to: 35 | input = input.replace( 36 | log['topics'][2], 37 | f"000000000000000000000000{tx['from'].lower()}") 38 | 39 | return MintInfo(mint_num, tx['gasPrice'], tx['value'], mint_to, 40 | tx['to'], input, tx['hash'].hex(), tx['blockNumber'], 41 | tx['from']) 42 | 43 | def _is_mint_log(self, log: LogReceipt): 44 | return log['topics'][0] == self._w3.keccak( 45 | text="Transfer(address,address,uint256)" 46 | ) and log['topics'][1].hex( 47 | ) == '0x0000000000000000000000000000000000000000000000000000000000000000' 48 | 49 | def _is_mint_tx(self, receipt: TxReceipt, tx: TxData): 50 | if not receipt['logs']: 51 | return False 52 | 53 | for log in receipt['logs']: 54 | if self._is_mint_log(log): 55 | # 检查是否为 erc721 56 | code = self._w3.eth.get_code(tx['to']) 57 | sighashes = self._contract_service.get_function_sighashes( 58 | code.hex()) 59 | if self._contract_service.is_erc721_contract(sighashes): 60 | return True 61 | 62 | return False 63 | 64 | def handle_tx(self, tx: TxData): 65 | to_address = tx['to'] 66 | if not to_address: 67 | return 68 | 69 | # 首先检查是否在 redis 中 70 | contract = backend.get_contract(to_address) 71 | 72 | receipt = self._w3.eth.get_transaction_receipt(tx['hash']) 73 | if not contract: 74 | # 忽略非 mint 交易 75 | if not self._is_mint_tx(receipt, tx): 76 | return 77 | 78 | logger.info( 79 | f"Transaction {tx['hash'].hex()} is a mint transaction.") 80 | info = self._verifier.get_verify_info(to_address) 81 | if not info: 82 | logger.info(f"Failed to verify contract {to_address}") 83 | return 84 | logger.info(f"Verify contract {to_address}({info['name']}).") 85 | backend.update_contract(to_address, info) 86 | 87 | mint_info = self.parse_mint_info(receipt, tx) 88 | logger.info(f"Transaction mint info {mint_info}") 89 | backend.add_mint(mint_info) 90 | 91 | def run(self): 92 | logger.info(f"BlockListener started.") 93 | seen = set() 94 | while True: 95 | block = self._w3.eth.get_block('latest', True) 96 | if block['hash'].hex() not in seen: 97 | logger.info( 98 | f"Get latest block number {block['number']} {block['hash'].hex()} transactions num {len(block['transactions'])}." 99 | ) 100 | for tx in block['transactions']: 101 | self.handle_tx(tx) 102 | 103 | seen.add(block['hash'].hex()) 104 | -------------------------------------------------------------------------------- /mintbot/contract_verifier.py: -------------------------------------------------------------------------------- 1 | from loguru import logger 2 | from .storage import backend 3 | import requests 4 | from web3 import Web3 5 | import json 6 | from opensea import OpenseaAPI 7 | 8 | 9 | class ContractVerifier(object): 10 | 11 | def __init__(self, etherscan_api_key, opensea_api_key, w3: Web3): 12 | self._etherscan_api_key = etherscan_api_key 13 | self._opensea_api = OpenseaAPI(apikey=opensea_api_key) 14 | self._w3 = w3 15 | 16 | def get_verify_info(self, contract): 17 | name, abi = self.verify_etherscan(contract) 18 | if not abi: 19 | return {} 20 | image_url, slug, external_url = self.verify_opensesa(contract) 21 | return { 22 | "verified": 1, 23 | "image_url": image_url, 24 | "opensea_url": f"https://opensea.io/collection/{slug}", 25 | "abi": abi, 26 | "name": name, 27 | "external_url": external_url, 28 | "etherscan_url": f"https://etherscan.io/address/{contract}", 29 | } 30 | 31 | def verify_etherscan(self, contract): 32 | abi = self._get_abi_for_contract(contract) 33 | if not abi: 34 | return "", "" 35 | 36 | name = self._fetch_contract_info(contract, abi) 37 | return name, json.dumps(abi) 38 | 39 | def verify_opensesa(self, contract): 40 | try: 41 | collection = self._opensea_api.contract( 42 | asset_contract_address=contract)['collection'] 43 | return collection['image_url'] or "", collection[ 44 | 'slug'], collection['external_url'] or "" 45 | except Exception as e: 46 | logger.error(f"Failed to fetch collection of {contract} error {e}") 47 | return "", "", "" 48 | 49 | def _get_abi_for_contract(self, contract): 50 | try: 51 | respone = requests.get('https://api.etherscan.io/api', 52 | params={ 53 | 'module': 'contract', 54 | 'action': 'getabi', 55 | 'address': contract, 56 | 'api_key': self._etherscan_api_key, 57 | }).json() 58 | return json.loads(respone['result']) 59 | 60 | except Exception as e: 61 | logger.error( 62 | f"Failed to fetch abi for {contract}, response {respone} error {e}" 63 | ) 64 | return None 65 | 66 | def _fetch_contract_info(self, contract, abi): 67 | try: 68 | # use abi to fecth name 69 | contract = self._w3.eth.contract( 70 | address=self._w3.toChecksumAddress(contract), abi=abi) 71 | name = contract.functions.name().call() 72 | return name 73 | except Exception as e: 74 | logger.error(f"Failed to call name of {contract}, error {e}") 75 | return "" 76 | -------------------------------------------------------------------------------- /mintbot/eth_contract_service.py: -------------------------------------------------------------------------------- 1 | """参考子 ethereumetl_airflow 代码,修正其中一个 bug 2 | """ 3 | 4 | from eth_utils import function_signature_to_4byte_selector 5 | 6 | from ethereum_dasm.evmdasm import EvmCode, Contract 7 | 8 | 9 | class EthContractService: 10 | 11 | def get_function_sighashes(self, bytecode): 12 | bytecode = clean_bytecode(bytecode) 13 | if bytecode is not None: 14 | evm_code = EvmCode(contract=Contract(bytecode=bytecode), static_analysis=False, dynamic_analysis=False) 15 | evm_code.disassemble(bytecode) 16 | basic_blocks = evm_code.basicblocks 17 | result = [] 18 | for block in basic_blocks: 19 | instructions = block.instructions 20 | push4_instructions = [inst for inst in instructions if inst.name == 'PUSH4'] 21 | result.extend(push4_instructions) 22 | return sorted(list(set('0x' + inst.operand for inst in result))) 23 | else: 24 | return [] 25 | 26 | # https://github.com/ethereum/EIPs/blob/master/EIPS/eip-20.md 27 | # https://github.com/OpenZeppelin/openzeppelin-solidity/blob/master/contracts/token/ERC20/ERC20.sol 28 | def is_erc20_contract(self, function_sighashes): 29 | c = ContractWrapper(function_sighashes) 30 | return c.implements('totalSupply()') and \ 31 | c.implements('balanceOf(address)') and \ 32 | c.implements('transfer(address,uint256)') and \ 33 | c.implements('transferFrom(address,address,uint256)') and \ 34 | c.implements('approve(address,uint256)') and \ 35 | c.implements('allowance(address,address)') 36 | 37 | # https://github.com/ethereum/EIPs/blob/master/EIPS/eip-721.md 38 | # https://github.com/OpenZeppelin/openzeppelin-solidity/blob/master/contracts/token/ERC721/ERC721Basic.sol 39 | # Doesn't check the below ERC721 methods to match CryptoKitties contract 40 | # getApproved(uint256) 41 | # setApprovalForAll(address,bool) 42 | # isApprovedForAll(address,address) 43 | # transferFrom(address,address,uint256) 44 | # safeTransferFrom(address,address,uint256) 45 | # safeTransferFrom(address,address,uint256,bytes) 46 | def is_erc721_contract(self, function_sighashes): 47 | c = ContractWrapper(function_sighashes) 48 | return c.implements('balanceOf(address)') and \ 49 | c.implements('ownerOf(uint256)') and \ 50 | c.implements_any_of('transfer(address,uint256)', 'transferFrom(address,address,uint256)') and \ 51 | c.implements('approve(address,uint256)') 52 | 53 | 54 | def clean_bytecode(bytecode): 55 | if bytecode is None or bytecode == '0x': 56 | return None 57 | elif bytecode.startswith('0x'): 58 | return bytecode[2:] 59 | else: 60 | return bytecode 61 | 62 | 63 | def get_function_sighash(signature): 64 | return '0x' + function_signature_to_4byte_selector(signature).hex() 65 | 66 | 67 | class ContractWrapper: 68 | def __init__(self, sighashes): 69 | self.sighashes = sighashes 70 | 71 | def implements(self, function_signature): 72 | sighash = get_function_sighash(function_signature) 73 | return sighash in self.sighashes 74 | 75 | def implements_any_of(self, *function_signatures): 76 | return any(self.implements(function_signature) for function_signature in function_signatures) 77 | -------------------------------------------------------------------------------- /mintbot/storage.py: -------------------------------------------------------------------------------- 1 | from redis import StrictRedis 2 | from datetime import datetime, timedelta 3 | from collections import defaultdict 4 | from .types import MintInfo 5 | import pickle 6 | 7 | 8 | class Storage(object): 9 | 10 | def __init__(self): 11 | self._db = StrictRedis() 12 | 13 | def _format_key(self, *args): 14 | return ':'.join(args) 15 | 16 | def verified(self, address): 17 | """仅处理已经在 etherscan 开源的 erc721 合约 18 | """ 19 | contract = self.get_contract(address) 20 | return contract and contract['verified'] 21 | 22 | def del_contract(self, contract): 23 | key = self._format_key("contracts", str(contract)) 24 | print(key) 25 | # contracts:0x6d3Adf9fEF24dDB3fFd0457455176dC6c437199f 26 | # contracts:0x6d3adf9fef24ddb3ffd0457455176dc6c437199f 27 | self._db.delete(key) 28 | 29 | def update_contract(self, contract, value): 30 | self._update_contract(contract, value) 31 | 32 | def _update_contract(self, contract, value): 33 | contract = contract.lower() 34 | 35 | key = self._format_key("contracts", contract) 36 | self._db.hmset(key, value) 37 | 38 | def get_contract(self, contract): 39 | contract = contract.lower() 40 | 41 | key = self._format_key("contracts", contract) 42 | value = self._db.hgetall(key) 43 | result = {} 44 | for k, v in value.items(): 45 | result[k.decode()] = v.decode() 46 | return result 47 | 48 | def add_mint(self, mint_info: MintInfo): 49 | key = self._format_key("mints") 50 | member = pickle.dumps(mint_info) 51 | score = datetime.now().timestamp() 52 | self._db.zadd(key, {member: score}) 53 | 54 | def get_mint_stats(self): 55 | # 要统计两部分数据 56 | # 1. 按照区块高度统计 mint tx 信息 57 | # 2. 按照时间统计项目的整体 mint 信息 58 | 59 | key = self._format_key("mints") 60 | # 只保存一小时的 mint 结果 61 | self._db.zremrangebyscore(key, 0, datetime.now().timestamp() - 3600) 62 | 63 | block_mints = defaultdict(list) # block_number->txs 64 | time_mints = defaultdict(list) 65 | 66 | now = datetime.now() 67 | for member, score in self._db.zrange(key, 0, -1, withscores=True): 68 | mint_time = datetime.fromtimestamp(score) 69 | mint_info: MintInfo = pickle.loads(member) 70 | # 区块 mint 信息 71 | block_mints[mint_info.block_number].append(mint_info) 72 | 73 | for delta in [3, 5, 30, 60]: 74 | if mint_time - now < timedelta(minutes=delta): 75 | # 3min 76 | time_mints[f'mint_{delta}m'].append(pickle.loads(member)) 77 | 78 | stats = defaultdict(dict) 79 | for k, v in time_mints.items(): 80 | contracts = defaultdict(lambda : [set(), 0]) 81 | for mint_info in v: 82 | contract = mint_info.contract 83 | contracts[contract][0].add(mint_info.from_address) 84 | contracts[contract][1] = contracts[contract][1] + 1 85 | 86 | lst = [] 87 | for kk, vv in contracts.items(): 88 | contract = self.get_contract(kk) 89 | lst.append([contract['name'], contract['opensea_url'], contract['etherscan_url'], contract['image_url'], len(vv[0]), vv[1]]) 90 | 91 | stats[k] = sorted(lst, key=lambda x: x[-1]) 92 | 93 | return block_mints, stats 94 | 95 | def list_all_contracts(self): 96 | keys = self._db.keys(self._format_key("contracts", "*")) 97 | return [self._db.hgetall(key) for key in keys] 98 | 99 | 100 | backend = Storage() 101 | 102 | -------------------------------------------------------------------------------- /mintbot/types.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | 4 | MintInfo = namedtuple('MintInfo', [ 5 | 'num', 'gas_price', 'value', 'mint_to', 'contract', 'input', 'tx_hash', 6 | 'block_number', 'from_address' 7 | ]) 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.8.1 2 | aiosignal==1.2.0 3 | async-timeout==4.0.2 4 | attrs==21.4.0 5 | base58==2.1.1 6 | bitarray==1.2.2 7 | certifi==2022.5.18.1 8 | charset-normalizer==2.0.12 9 | click==8.0.4 10 | colorama==0.4.4 11 | cytoolz==0.11.2 12 | Deprecated==1.2.13 13 | eth-abi==2.1.1 14 | eth-account==0.5.7 15 | eth-hash==0.3.2 16 | eth-keyfile==0.5.1 17 | eth-keys==0.3.4 18 | eth-rlp==0.3.0 19 | eth-typing==2.3.0 20 | eth-utils==1.10.0 21 | ethereum-dasm==0.1.4 22 | ethereum-etl==2.0.2 23 | evmdasm==0.1.10 24 | frozenlist==1.3.0 25 | hexbytes==0.2.2 26 | idna==3.3 27 | importlib-resources==5.7.1 28 | ipfshttpclient==0.8.0a2 29 | jsonschema==4.5.1 30 | loguru==0.6.0 31 | lru-dict==1.1.7 32 | multiaddr==0.0.9 33 | multidict==6.0.2 34 | netaddr==0.8.0 35 | opensea-api==0.1.7 36 | packaging==21.3 37 | parsimonious==0.8.1 38 | protobuf==3.20.1 39 | pycryptodome==3.14.1 40 | pyparsing==3.0.9 41 | pyrsistent==0.18.1 42 | python-dateutil==2.8.2 43 | redis==4.3.3 44 | requests==2.27.1 45 | rlp==2.0.1 46 | six==1.16.0 47 | tabulate==0.8.9 48 | toolz==0.11.2 49 | urllib3==1.26.9 50 | varint==1.0.2 51 | web3==5.29.1 52 | websockets==9.1 53 | wrapt==1.14.1 54 | yapf==0.32.0 55 | yarl==1.7.2 56 | zipp==3.8.0 57 | --------------------------------------------------------------------------------