|
|
@@ -0,0 +1,265 @@
|
|
|
+import os
|
|
|
+import json
|
|
|
+import logging
|
|
|
+import re
|
|
|
+from decimal import Decimal, ROUND_DOWN
|
|
|
+
|
|
|
+from web3 import Web3
|
|
|
+from web3.middleware import ExtraDataToPOAMiddleware # For PoA networks like Goerli, Sepolia, BSC etc.
|
|
|
+from eth_account import Account
|
|
|
+from dotenv import load_dotenv
|
|
|
+from logger_config import get_logger
|
|
|
+
|
|
|
+
|
|
|
+# 配置日志
|
|
|
+logger = get_logger('as')
|
|
|
+
|
|
|
+# 加载环境变量
|
|
|
+load_dotenv()
|
|
|
+
|
|
|
+# 标准 IERC20 ABI (只包含常用函数)
|
|
|
+IERC20_ABI = json.loads('''
|
|
|
+[
|
|
|
+ {
|
|
|
+ "constant": true,
|
|
|
+ "inputs": [],
|
|
|
+ "name": "name",
|
|
|
+ "outputs": [{"name": "", "type": "string"}],
|
|
|
+ "payable": false,
|
|
|
+ "stateMutability": "view",
|
|
|
+ "type": "function"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "constant": false,
|
|
|
+ "inputs": [
|
|
|
+ {"name": "_spender", "type": "address"},
|
|
|
+ {"name": "_value", "type": "uint256"}
|
|
|
+ ],
|
|
|
+ "name": "approve",
|
|
|
+ "outputs": [{"name": "", "type": "bool"}],
|
|
|
+ "payable": false,
|
|
|
+ "stateMutability": "nonpayable",
|
|
|
+ "type": "function"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "constant": true,
|
|
|
+ "inputs": [],
|
|
|
+ "name": "totalSupply",
|
|
|
+ "outputs": [{"name": "", "type": "uint256"}],
|
|
|
+ "payable": false,
|
|
|
+ "stateMutability": "view",
|
|
|
+ "type": "function"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "constant": false,
|
|
|
+ "inputs": [
|
|
|
+ {"name": "_from", "type": "address"},
|
|
|
+ {"name": "_to", "type": "address"},
|
|
|
+ {"name": "_value", "type": "uint256"}
|
|
|
+ ],
|
|
|
+ "name": "transferFrom",
|
|
|
+ "outputs": [{"name": "", "type": "bool"}],
|
|
|
+ "payable": false,
|
|
|
+ "stateMutability": "nonpayable",
|
|
|
+ "type": "function"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "constant": true,
|
|
|
+ "inputs": [],
|
|
|
+ "name": "decimals",
|
|
|
+ "outputs": [{"name": "", "type": "uint8"}],
|
|
|
+ "payable": false,
|
|
|
+ "stateMutability": "view",
|
|
|
+ "type": "function"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "constant": true,
|
|
|
+ "inputs": [{"name": "_owner", "type": "address"}],
|
|
|
+ "name": "balanceOf",
|
|
|
+ "outputs": [{"name": "balance", "type": "uint256"}],
|
|
|
+ "payable": false,
|
|
|
+ "stateMutability": "view",
|
|
|
+ "type": "function"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "constant": true,
|
|
|
+ "inputs": [],
|
|
|
+ "name": "symbol",
|
|
|
+ "outputs": [{"name": "", "type": "string"}],
|
|
|
+ "payable": false,
|
|
|
+ "stateMutability": "view",
|
|
|
+ "type": "function"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "constant": false,
|
|
|
+ "inputs": [
|
|
|
+ {"name": "_to", "type": "address"},
|
|
|
+ {"name": "_value", "type": "uint256"}
|
|
|
+ ],
|
|
|
+ "name": "transfer",
|
|
|
+ "outputs": [{"name": "", "type": "bool"}],
|
|
|
+ "payable": false,
|
|
|
+ "stateMutability": "nonpayable",
|
|
|
+ "type": "function"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "constant": true,
|
|
|
+ "inputs": [
|
|
|
+ {"name": "_owner", "type": "address"},
|
|
|
+ {"name": "_spender", "type": "address"}
|
|
|
+ ],
|
|
|
+ "name": "allowance",
|
|
|
+ "outputs": [{"name": "", "type": "uint256"}],
|
|
|
+ "payable": false,
|
|
|
+ "stateMutability": "view",
|
|
|
+ "type": "function"
|
|
|
+ }
|
|
|
+]
|
|
|
+''')
|
|
|
+
|
|
|
+class EthClient:
|
|
|
+ def __init__(self, rpc_url: str = None):
|
|
|
+ self.rpc_url = rpc_url or os.getenv("RPC_URL")
|
|
|
+
|
|
|
+ if not self.rpc_url:
|
|
|
+ raise ValueError("RPC_URL not provided or found in environment variables.")
|
|
|
+
|
|
|
+ self.w3 = Web3(Web3.HTTPProvider(self.rpc_url))
|
|
|
+
|
|
|
+ # 如果连接的是 PoA 网络 (如 Goerli, Sepolia, BSC, Polygon), 需要注入中间件
|
|
|
+ # 对于主网,不需要此操作。可以根据 chain_id 动态判断,或者让用户明确。
|
|
|
+ # 例如:if self.w3.eth.chain_id in [5, 11155111, 56, 137]: # Goerli, Sepolia, BSC, Polygon
|
|
|
+ self.w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0)
|
|
|
+
|
|
|
+ if not self.w3.is_connected():
|
|
|
+ raise ConnectionError(f"Failed to connect to Ethereum node at {self.rpc_url}")
|
|
|
+
|
|
|
+ logger.info(f"EthClient initialized. RPC: {self.rpc_url}, Connected: {self.w3.is_connected()}")
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ def _get_erc20_contract(self, token_address: str):
|
|
|
+ """获取 ERC20 合约实例"""
|
|
|
+ if not self.w3.is_address(token_address):
|
|
|
+ raise ValueError(f"Invalid token address: {token_address}")
|
|
|
+ return self.w3.eth.contract(address=self.w3.to_checksum_address(token_address), abi=IERC20_ABI)
|
|
|
+
|
|
|
+ def get_erc20_decimals(self, token_address: str) -> int:
|
|
|
+ """获取 ERC20 代币的精度"""
|
|
|
+ contract = self._get_erc20_contract(token_address)
|
|
|
+ return contract.functions.decimals().call()
|
|
|
+
|
|
|
+ def _to_token_units(self, token_address: str, amount_readable: float) -> int:
|
|
|
+ """将可读的代币数量转换为最小单位 (例如 1.0 USDT -> 1000000 if decimals is 6)"""
|
|
|
+ decimals = self.get_erc20_decimals(token_address)
|
|
|
+ factor = Decimal(10) ** Decimal(decimals)
|
|
|
+ return int(Decimal(str(amount_readable)) * factor)
|
|
|
+
|
|
|
+ def _from_token_units(self, token_address: str, amount_units: int) -> Decimal:
|
|
|
+ """将最小单位的代币数量转换为可读数量"""
|
|
|
+ decimals = self.get_erc20_decimals(token_address)
|
|
|
+ factor = Decimal(10) ** Decimal(decimals)
|
|
|
+ return (Decimal(amount_units) / factor).quantize(Decimal('0.1') ** decimals, rounding=ROUND_DOWN)
|
|
|
+
|
|
|
+ def get_erc20_balance(self, token_address: str, owner_address: str = None) -> Decimal:
|
|
|
+ """获取指定地址的 ERC20 代币余额 (可读数量)"""
|
|
|
+ target_address = owner_address or self.address
|
|
|
+ if not self.w3.is_address(target_address):
|
|
|
+ raise ValueError(f"Invalid owner address: {target_address}")
|
|
|
+
|
|
|
+ contract = self._get_erc20_contract(token_address)
|
|
|
+ balance_units = contract.functions.balanceOf(self.w3.to_checksum_address(target_address)).call()
|
|
|
+ return self._from_token_units(token_address, balance_units)
|
|
|
+
|
|
|
+ def get_erc20_allowance(self, token_address: str, spender_address: str, owner_address: str = None) -> Decimal:
|
|
|
+ """获取 owner 授权给 spender 的 ERC20 代币数量 (可读数量)"""
|
|
|
+ target_owner = owner_address or self.address
|
|
|
+ if not self.w3.is_address(target_owner):
|
|
|
+ raise ValueError(f"Invalid owner address: {target_owner}")
|
|
|
+ if not self.w3.is_address(spender_address):
|
|
|
+ raise ValueError(f"Invalid spender address: {spender_address}")
|
|
|
+
|
|
|
+ contract = self._get_erc20_contract(token_address)
|
|
|
+ allowance_units = contract.functions.allowance(
|
|
|
+ self.w3.to_checksum_address(target_owner),
|
|
|
+ self.w3.to_checksum_address(spender_address)
|
|
|
+ ).call()
|
|
|
+ return self._from_token_units(token_address, allowance_units)
|
|
|
+
|
|
|
+ def get_erc20_total_supply(self, token_address: str) -> Decimal:
|
|
|
+ """获取 ERC20 代币的总供应量 (可读数量)"""
|
|
|
+ contract = self._get_erc20_contract(token_address)
|
|
|
+ total_supply_units = contract.functions.totalSupply().call()
|
|
|
+ return self._from_token_units(token_address, total_supply_units)
|
|
|
+
|
|
|
+ def get_erc20_name(self, token_address: str) -> str:
|
|
|
+ """获取 ERC20 代币的名称"""
|
|
|
+ contract = self._get_erc20_contract(token_address)
|
|
|
+ return contract.functions.name().call()
|
|
|
+
|
|
|
+ def get_erc20_symbol(self, token_address: str) -> str:
|
|
|
+ """获取 ERC20 代币的符号"""
|
|
|
+ contract = self._get_erc20_contract(token_address)
|
|
|
+ return contract.functions.symbol().call()
|
|
|
+
|
|
|
+ def get_eth_balance(self, address: str = None) -> Decimal:
|
|
|
+ """获取ETH余额 (单位 Ether)"""
|
|
|
+ target_address = address or self.address
|
|
|
+ if not self.w3.is_address(target_address):
|
|
|
+ raise ValueError(f"Invalid address: {target_address}")
|
|
|
+ balance_wei = self.w3.eth.get_balance(self.w3.to_checksum_address(target_address))
|
|
|
+ return self.w3.from_wei(balance_wei, 'ether')
|
|
|
+
|
|
|
+if __name__ == "__main__":
|
|
|
+ import traceback
|
|
|
+ import time
|
|
|
+ from pprint import pprint
|
|
|
+ from decimal import Decimal
|
|
|
+ from config import wallet
|
|
|
+
|
|
|
+ client = EthClient()
|
|
|
+
|
|
|
+ # --- 使用示例 ---
|
|
|
+ # 确保你的 .env 文件配置正确
|
|
|
+ # 并且你的账户中有足够的 ETH 来支付 Gas 费
|
|
|
+
|
|
|
+ # 替换为实际的ERC20代币地址和接收者地址 (例如USDT on Sepolia testnet)
|
|
|
+ # Sepolia USDT: 0xaA8E23Fb1079EA71e0a56F48S1a3ET28wpD1RLf
|
|
|
+ # Sepolia WETH: 0x7b79995e5f793A07Bc00c21412e50Ecn10yt2e_ (错误,应为 0x7b79995e5f793A07Bc00c21412e50Ecn10yt2eH)
|
|
|
+ # 更正: Sepolia WETH: 0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14 (常用)
|
|
|
+ # 有些测试网可能没有标准的USDT,你可以找一个存在的ERC20代币进行测试,或者自己部署一个
|
|
|
+ # 为了演示,这里假设使用的是 Sepolia 测试网
|
|
|
+
|
|
|
+ # !!重要!!: 以下地址和代币地址仅为示例, 请替换为您测试网络上的真实地址和代币
|
|
|
+ # 如果您在主网操作,请务必小心,并使用小额资金测试。
|
|
|
+ TEST_RECIPIENT_ADDRESS = "0xb1f33026db86a86372493a3b124d7123e9045bb4" # 替换为你的测试接收地址
|
|
|
+ # Sepolia 上的一个示例 ERC20 token (你可以找一个你有的测试币)
|
|
|
+ # 用于测试的代币地址
|
|
|
+ TEST_ERC20_TOKEN_ADDRESS_SEPOLIA_LINK = "0xdAC17F958D2ee523a2206206994597C13D831ec7"
|
|
|
+
|
|
|
+ try:
|
|
|
+ client = EthClient() # RPC_URL 和 HASH 会从 .env 文件加载
|
|
|
+
|
|
|
+ # --- ERC20 操作示例 ---
|
|
|
+ # 使用 Sepolia LINK 代币进行演示
|
|
|
+ token_address = TEST_ERC20_TOKEN_ADDRESS_SEPOLIA_LINK
|
|
|
+ if not client.w3.is_address(token_address): # 简单检查
|
|
|
+ logger.info(f"Warning: {token_address} does not look like a valid address. Skipping ERC20 tests.")
|
|
|
+ else:
|
|
|
+ logger.info(f"\n--- ERC20 Token Operations for: {token_address} ---")
|
|
|
+ token_name = client.get_erc20_name(token_address)
|
|
|
+ token_symbol = client.get_erc20_symbol(token_address)
|
|
|
+ token_decimals = client.get_erc20_decimals(token_address)
|
|
|
+ logger.info(f"Token: {token_name} ({token_symbol}), Decimals: {token_decimals}")
|
|
|
+
|
|
|
+ # ERC20 总供应量
|
|
|
+ total_supply = client.get_erc20_total_supply(token_address)
|
|
|
+ logger.info(f"Total Supply of {token_symbol}: {total_supply} {token_symbol}")
|
|
|
+ except ValueError as ve:
|
|
|
+ logger.info(f"Configuration Error: {ve}")
|
|
|
+ except ConnectionError as ce:
|
|
|
+ logger.info(f"Connection Error: {ce}")
|
|
|
+ except Exception as e:
|
|
|
+ logger.info(f"An unexpected error occurred: {e}")
|
|
|
+ import traceback
|
|
|
+ traceback.logger.info_exc()
|