├── .DS_Store ├── .gitignore ├── README.md ├── __pycache__ ├── aevo.cpython-311.pyc └── eip712_structs.cpython-311.pyc ├── aevo.py ├── aevo_market_price_trade.py ├── aevo_option_trade.py ├── aevo_trade.py ├── create_apiKey.py ├── data ├── .DS_Store └── input │ ├── .DS_Store │ └── aevoAccount.csv ├── eip712_structs.py └── requirements.txt /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shuail0/aevoTrading/4f0890bf65be260fdbda9291fd0ffcdb8af7582c/.DS_Store -------------------------------------------------------------------------------- /.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 | # AEVO 刷交易量程序 2 | 3 | # 更新记录 4 | 5 | ## 2024/2/22 6 | 7 | 增加使用市价单成交的策略,用市价成交比原来的策略成本要低(在ETH刷10万U交易成本20U以内)并且能减少挂单无法成交导致长时间持仓产生亏损,代码在`aevo_market_price_trade.py`: 8 | 9 | - 检查盘口买卖价差是否低于0.05%,如果低于开始交易。 10 | - 以市价卖出,并以卖一价格*1.02买入平仓。 11 | - 检查账户有没有持仓,如果有持仓以市价平仓。 12 | 13 | ## 2024/2/20 14 | 增加订单检测,每次交易完成检查是否空仓,如果不是空仓市价平仓并取消所有挂单。 15 | 16 | # 项目介绍 17 | 18 | AEVO是一个去中心化衍生品交易平台,主要交易品种是永续合约和期权。项目前身是Ribbon Finance,项目获得了Paradigm、Dragonfly和Coinbase等资本方的投资,项目最近上合约的速度非常快,很多项目在。 19 | 20 | 项目原先发过一个RBN的代币,并且21年发过一次空投,平均一个地址空投了50万RMB。未来老币RBN将置换成AEVO,根据文档中的描述,项目代币中16%的代币用于激励(包括空投),AEVO代币总量10亿枚,按照目前RBN的币价,这部分价值8000万U左右。 21 | 22 | 项目最近上了一个按交易量空投的活动。空投根据用户的空投交易量发放,空投交易量=交易量*boost因子,boost因子根据最近的7天交易量计算,交易越多提升越高。 23 | 24 | 具体的规则可以在这里查看:https://aevo.mirror.xyz/pVCrIjnPwDkC7h16vr_Ca__AdsXL31ZL2VylkICX0Ss 25 | 26 | 27 | 28 | 29 | # AEVO账户准备 30 | 31 | 项目链接: 32 | 33 | - 我的邀请链接:https://app.aevo.xyz/r/Roan-Elastic-Nakamoto 34 | 35 | 连接钱包后存入USDC,OP或者Arbitrum网络的都可以,费用都很低。 36 | 37 | ![image-20240219175549654](https://s2.loli.net/2024/02/19/nEeOGIydctkHRj9.png) 38 | 39 | ![image-20240219173411045](https://s2.loli.net/2024/02/19/DRpF82oZ3VP4NJy.png) 40 | 41 | 42 | 43 | # 创建API 44 | 45 | API可以直接在网站上创建或通过代码创建, 钱包数量少可以直接在网页创建,钱包多的可以用我的代码批量创建。 46 | 47 | ## 在网页中创建 48 | 49 | 访问:https://app.aevo.xyz/settings/api-keys 50 | 51 | 点击 Creaate API创建API,创建完成后可以点击API Key和APISecret复制。第一次复制Secret的时候需要在小狐狸签名确认。 52 | 53 | ![image-20240219174644354](https://s2.loli.net/2024/02/19/4JVzcHrZMx9pE7X.png) 54 | 55 | ![image-20240219174946898](https://s2.loli.net/2024/02/19/Q4dzXfoL3S1e7B9.png) 56 | 57 | 58 | 59 | ## 使用代码创建 60 | 61 | 用create_apiKey.py代码可以批量创建API,按照data/input目录下的aevoAccount.csv编辑自己的钱包文件,然后将路径填写到代码中,然后运行create_apiKey.py即可。 62 | 63 | ![image-20240219180019364](https://s2.loli.net/2024/02/19/EIPhs8g4fT6coWS.png) 64 | 65 | # 程序运行 66 | 67 | 68 | 69 | ## API配置 70 | 71 | 首先在代码目录下创建一个`.env`文件,然后按照下面的格式填入账户信息 72 | 73 | ``` bash 74 | SIGNING=钱包私钥 75 | WALLETADDRESS=钱包地址 76 | APIKEY=API_Key 77 | APISECRET=API_Seret 78 | ``` 79 | 80 | ![image-20240220191231393](https://s2.loli.net/2024/02/20/VlC2LGamAzyvHht.png) 81 | 82 | ## 永续合约 83 | 84 | 永续合约的交易程序有`aevo_trade.py`和`aevo_market_price_trade.py`两个,盘口价差比较小时推荐跑`aevo_market_price_trade.py`,价差比较大时推荐跑`aevo_trade.py` 85 | 86 | `aevo_trade.py`:交易策略是根据设置的品种和交易币数同时挂买卖单,挂单价格是买一和卖一的中间价,交易达到指定次数后程序停止运行。 87 | 88 | `aevo_market_price_trade.py`:(同时挂市价卖买单),流动性比较好的交易对推荐用挂市价单的方式刷,下图的配置部分根据实际情况进行配置,修改完毕后运行程序。 89 | 90 | 91 | ![image-20240220191348102](https://s2.loli.net/2024/02/20/P2Dr1LE5fuJRxhI.png) 92 | 93 | 94 | 95 | ## 期权合约 96 | 97 | 刷期权的程序是`aevo_option_trade.py`,交易策略是根据设定的价值同时挂相同价格的买单和卖单。 98 | 99 | 不知道怎么期权合约选择可以参考这篇推文:https://twitter.com/crypto0xLeo/status/1764333201487757542 100 | 101 | ![image-20240306234335847](https://s2.loli.net/2024/03/06/2YftBbA3q7ONcHd.png) 102 | 103 | 104 | 105 | `option_symbol`的值在选定合约后的浏览器地址输入框里面复制: 106 | 107 | ![image-20240306234651860](https://s2.loli.net/2024/03/06/FqihDQMGHoOX7LR.png) 108 | 109 | 110 | 111 | ## 其他 112 | 113 | 代码链接:https://github.com/shuail0/aevoTrading 114 | 115 | 欢迎关注推特:https://twitter.com/crypto0xLeo -------------------------------------------------------------------------------- /__pycache__/aevo.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shuail0/aevoTrading/4f0890bf65be260fdbda9291fd0ffcdb8af7582c/__pycache__/aevo.cpython-311.pyc -------------------------------------------------------------------------------- /__pycache__/eip712_structs.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shuail0/aevoTrading/4f0890bf65be260fdbda9291fd0ffcdb8af7582c/__pycache__/eip712_structs.cpython-311.pyc -------------------------------------------------------------------------------- /aevo.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import random 4 | import time 5 | import traceback 6 | 7 | import requests 8 | import websockets 9 | from eth_account import Account 10 | from eth_hash.auto import keccak 11 | from loguru import logger 12 | from web3 import Web3 13 | 14 | from eip712_structs import Address, Boolean, EIP712Struct, Uint, make_domain 15 | 16 | CONFIG = { 17 | "testnet": { 18 | "rest_url": "https://api-testnet.aevo.xyz", 19 | "ws_url": "wss://ws-testnet.aevo.xyz", 20 | "signing_domain": { 21 | "name": "Aevo Testnet", 22 | "version": "1", 23 | "chainId": "11155111", 24 | }, 25 | }, 26 | "mainnet": { 27 | "rest_url": "https://api.aevo.xyz", 28 | "ws_url": "wss://ws.aevo.xyz", 29 | "signing_domain": { 30 | "name": "Aevo Mainnet", 31 | "version": "1", 32 | "chainId": "1", 33 | }, 34 | }, 35 | } 36 | 37 | 38 | class Order(EIP712Struct): 39 | maker = Address() 40 | isBuy = Boolean() 41 | limitPrice = Uint(256) 42 | amount = Uint(256) 43 | salt = Uint(256) 44 | instrument = Uint(256) 45 | timestamp = Uint(256) 46 | 47 | 48 | class AevoClient: 49 | def __init__( 50 | self, 51 | signing_key="", 52 | wallet_address="", 53 | api_key="", 54 | api_secret="", 55 | env="testnet", 56 | rest_headers={}, 57 | ): 58 | self.signing_key = signing_key 59 | self.wallet_address = wallet_address 60 | self.api_key = api_key 61 | self.api_secret = api_secret 62 | self.connection = None 63 | self.client = requests 64 | self.rest_headers = { 65 | "AEVO-KEY": api_key, 66 | "AEVO-SECRET": api_secret, 67 | } 68 | self.extra_headers = None 69 | self.rest_headers.update(rest_headers) 70 | 71 | if (env != "testnet") and (env != "mainnet"): 72 | raise ValueError("env must either be 'testnet' or 'mainnet'") 73 | self.env = env 74 | 75 | @property 76 | def address(self): 77 | return Account.from_key(self.signing_key).address 78 | 79 | @property 80 | def rest_url(self): 81 | return CONFIG[self.env]["rest_url"] 82 | 83 | @property 84 | def ws_url(self): 85 | return CONFIG[self.env]["ws_url"] 86 | 87 | @property 88 | def signing_domain(self): 89 | return CONFIG[self.env]["signing_domain"] 90 | 91 | async def open_connection(self, extra_headers={}): 92 | try: 93 | logger.info("Opening Aevo websocket connection...") 94 | 95 | self.connection = await websockets.connect( 96 | self.ws_url, ping_interval=None, extra_headers=extra_headers 97 | ) 98 | if not self.extra_headers: 99 | self.extra_headers = extra_headers 100 | 101 | if self.api_key and self.wallet_address: 102 | logger.debug(f"Connecting to {self.ws_url}...") 103 | await self.connection.send( 104 | json.dumps( 105 | { 106 | "id": 1, 107 | "op": "auth", 108 | "data": { 109 | "key": self.api_key, 110 | "secret": self.api_secret, 111 | }, 112 | } 113 | ) 114 | ) 115 | 116 | # Sleep as authentication takes some time, especially slower on testnet 117 | await asyncio.sleep(1) 118 | except Exception as e: 119 | logger.error("Error thrown when opening connection") 120 | logger.error(e) 121 | logger.error(traceback.format_exc()) 122 | await asyncio.sleep(10) # Don't retry straight away 123 | 124 | async def reconnect(self): 125 | logger.info("Trying to reconnect Aevo websocket...") 126 | await self.close_connection() 127 | await self.open_connection(self.extra_headers) 128 | 129 | async def close_connection(self): 130 | try: 131 | logger.info("Closing connection...") 132 | await self.connection.close() 133 | logger.info("Connection closed") 134 | except Exception as e: 135 | logger.error("Error thrown when closing connection") 136 | logger.error(e) 137 | logger.error(traceback.format_exc()) 138 | 139 | async def read_messages(self, read_timeout=0.1, backoff=0.1, on_disconnect=None): 140 | while True: 141 | try: 142 | message = await asyncio.wait_for( 143 | self.connection.recv(), timeout=read_timeout 144 | ) 145 | yield message 146 | except ( 147 | websockets.exceptions.ConnectionClosedError, 148 | websockets.exceptions.ConnectionClosedOK, 149 | ) as e: 150 | if on_disconnect: 151 | on_disconnect() 152 | logger.error("Aevo websocket connection close") 153 | logger.error(e) 154 | logger.error(traceback.format_exc()) 155 | await self.reconnect() 156 | except asyncio.TimeoutError: 157 | await asyncio.sleep(backoff) 158 | except Exception as e: 159 | logger.error(e) 160 | logger.error(traceback.format_exc()) 161 | await asyncio.sleep(1) 162 | 163 | async def send(self, data): 164 | try: 165 | await self.connection.send(data) 166 | except websockets.exceptions.ConnectionClosedError as e: 167 | logger.debug("Restarted Aevo websocket connection") 168 | await self.reconnect() 169 | await self.connection.send(data) 170 | except: 171 | await self.reconnect() 172 | 173 | # Public REST API 174 | def get_index(self, asset): 175 | req = self.client.get(f"{self.rest_url}/index?symbol={asset}") 176 | data = req.json() 177 | return data 178 | 179 | def get_markets(self, asset): 180 | req = self.client.get(f"{self.rest_url}/markets?asset={asset}") 181 | data = req.json() 182 | return data 183 | 184 | # Private REST API 185 | def rest_create_order( 186 | self, instrument_id, is_buy, limit_price, quantity, post_only=True 187 | ): 188 | data, order_id = self.create_order_rest_json( 189 | int(instrument_id), is_buy, limit_price, quantity, post_only 190 | ) 191 | logger.info(data) 192 | req = self.client.post( 193 | f"{self.rest_url}/orders", json=data, headers=self.rest_headers 194 | ) 195 | try: 196 | return req.json() 197 | except: 198 | return req.text() 199 | 200 | def rest_create_market_order(self, instrument_id, is_buy, quantity): 201 | limit_price = 0 202 | if is_buy: 203 | limit_price = 2**256 - 1 204 | 205 | data, order_id = self.create_order_rest_json( 206 | int(instrument_id), 207 | is_buy, 208 | limit_price, 209 | quantity, 210 | decimals=1, 211 | post_only=False, 212 | ) 213 | 214 | req = self.client.post( 215 | f"{self.rest_url}/orders", json=data, headers=self.rest_headers 216 | ) 217 | return req.json() 218 | 219 | def rest_cancel_order(self, order_id): 220 | req = self.client.delete( 221 | f"{self.rest_url}/orders/{order_id}", headers=self.rest_headers 222 | ) 223 | logger.info(req.json()) 224 | return req.json() 225 | 226 | def rest_get_account(self): 227 | req = self.client.get(f"{self.rest_url}/account", headers=self.rest_headers) 228 | return req.json() 229 | 230 | def rest_get_portfolio(self): 231 | req = self.client.get(f"{self.rest_url}/portfolio", headers=self.rest_headers) 232 | return req.json() 233 | 234 | def rest_get_open_orders(self): 235 | req = self.client.get( 236 | f"{self.rest_url}/orders", json={}, headers=self.rest_headers 237 | ) 238 | return req.json() 239 | 240 | def rest_cancel_all_orders( 241 | self, 242 | instrument_type=None, 243 | asset=None, 244 | ): 245 | body = {} 246 | if instrument_type: 247 | body["instrument_type"] = instrument_type 248 | 249 | if asset: 250 | body["asset"] = asset 251 | 252 | req = self.client.delete( 253 | f"{self.rest_url}/orders-all", json=body, headers=self.rest_headers 254 | ) 255 | return req.json() 256 | 257 | # Public WS Subscriptions 258 | async def subscribe_tickers(self, asset): 259 | await self.send( 260 | json.dumps( 261 | { 262 | "op": "subscribe", 263 | "data": [f"ticker:{asset}:OPTION"], 264 | } 265 | ) 266 | ) 267 | 268 | async def subscribe_ticker(self, channel): 269 | msg = json.dumps( 270 | { 271 | "op": "subscribe", 272 | "data": [channel], 273 | } 274 | ) 275 | await self.send(msg) 276 | 277 | async def subscribe_markprice(self, asset): 278 | await self.send( 279 | json.dumps( 280 | { 281 | "op": "subscribe", 282 | "data": [f"markprice:{asset}:OPTION"], 283 | } 284 | ) 285 | ) 286 | 287 | async def subscribe_orderbook(self, instrument_name): 288 | await self.send( 289 | json.dumps( 290 | { 291 | "op": "subscribe", 292 | "data": [f"orderbook:{instrument_name}"], 293 | } 294 | ) 295 | ) 296 | 297 | async def subscribe_trades(self, instrument_name): 298 | await self.send( 299 | json.dumps( 300 | { 301 | "op": "subscribe", 302 | "data": [f"trades:{instrument_name}"], 303 | } 304 | ) 305 | ) 306 | 307 | async def subscribe_index(self, asset): 308 | await self.send(json.dumps({"op": "subscribe", "data": [f"index:{asset}"]})) 309 | 310 | # Private WS Subscriptions 311 | async def subscribe_orders(self): 312 | payload = { 313 | "op": "subscribe", 314 | "data": ["orders"], 315 | } 316 | await self.send(json.dumps(payload)) 317 | 318 | async def subscribe_fills(self): 319 | payload = { 320 | "op": "subscribe", 321 | "data": ["fills"], 322 | } 323 | await self.send(json.dumps(payload)) 324 | 325 | # Private WS Commands 326 | def create_order_ws_json( 327 | self, 328 | instrument_id, 329 | is_buy, 330 | limit_price, 331 | quantity, 332 | post_only=True, 333 | mmp=True, 334 | price_decimals=10**6, 335 | amount_decimals=10**6, 336 | ): 337 | timestamp = int(time.time()) 338 | salt, signature, order_id = self.sign_order( 339 | instrument_id=instrument_id, 340 | is_buy=is_buy, 341 | limit_price=limit_price, 342 | quantity=quantity, 343 | timestamp=timestamp, 344 | price_decimals=price_decimals, 345 | ) 346 | 347 | payload = { 348 | "instrument": instrument_id, 349 | "maker": self.wallet_address, 350 | "is_buy": is_buy, 351 | "amount": str(int(round(quantity * amount_decimals, is_buy))), 352 | "limit_price": str(int(round(limit_price * price_decimals, is_buy))), 353 | "salt": str(salt), 354 | "signature": signature, 355 | "post_only": post_only, 356 | "mmp": mmp, 357 | "timestamp": timestamp, 358 | } 359 | return payload, order_id 360 | 361 | def create_order_rest_json( 362 | self, 363 | instrument_id, 364 | is_buy, 365 | limit_price, 366 | quantity, 367 | post_only=True, 368 | reduce_only=False, 369 | close_position=False, 370 | price_decimals=10**6, 371 | amount_decimals=10**6, 372 | trigger=None, 373 | stop=None, 374 | ): 375 | timestamp = int(time.time()) 376 | salt, signature, order_id = self.sign_order( 377 | instrument_id=instrument_id, 378 | is_buy=is_buy, 379 | limit_price=limit_price, 380 | quantity=quantity, 381 | timestamp=timestamp, 382 | price_decimals=price_decimals, 383 | ) 384 | payload = { 385 | "maker": self.wallet_address, 386 | "is_buy": is_buy, 387 | "instrument": instrument_id, 388 | "limit_price": str(int(round(limit_price * price_decimals, is_buy))), 389 | "amount": str(int(round(quantity * amount_decimals, is_buy))), 390 | "salt": str(salt), 391 | "signature": signature, 392 | "post_only": post_only, 393 | "reduce_only": reduce_only, 394 | "close_position": close_position, 395 | "timestamp": timestamp, 396 | } 397 | if trigger and stop: 398 | payload["trigger"] = trigger 399 | payload["stop"] = stop 400 | 401 | return payload, order_id 402 | 403 | async def create_order( 404 | self, 405 | instrument_id, 406 | is_buy, 407 | limit_price, 408 | quantity, 409 | post_only=True, 410 | id=None, 411 | mmp=True, 412 | ): 413 | data, order_id = self.create_order_ws_json( 414 | instrument_id=int(instrument_id), 415 | is_buy=is_buy, 416 | limit_price=limit_price, 417 | quantity=quantity, 418 | post_only=post_only, 419 | mmp=mmp, 420 | ) 421 | payload = {"op": "create_order", "data": data} 422 | if id: 423 | payload["id"] = id 424 | 425 | logger.info(payload) 426 | await self.send(json.dumps(payload)) 427 | 428 | return order_id 429 | 430 | async def edit_order( 431 | self, 432 | order_id, 433 | instrument_id, 434 | is_buy, 435 | limit_price, 436 | quantity, 437 | id=None, 438 | post_only=True, 439 | mmp=True, 440 | ): 441 | timestamp = int(time.time()) 442 | instrument_id = int(instrument_id) 443 | salt, signature, new_order_id = self.sign_order( 444 | instrument_id=instrument_id, 445 | is_buy=is_buy, 446 | limit_price=limit_price, 447 | quantity=quantity, 448 | timestamp=timestamp, 449 | ) 450 | payload = { 451 | "op": "edit_order", 452 | "data": { 453 | "order_id": order_id, 454 | "instrument": instrument_id, 455 | "maker": self.wallet_address, 456 | "is_buy": is_buy, 457 | "amount": str(int(round(quantity * 10**6, is_buy))), 458 | "limit_price": str(int(round(limit_price * 10**6, is_buy))), 459 | "salt": str(salt), 460 | "signature": signature, 461 | "post_only": post_only, 462 | "mmp": mmp, 463 | "timestamp": timestamp, 464 | }, 465 | } 466 | 467 | if id: 468 | payload["id"] = id 469 | 470 | logger.info(payload) 471 | await self.send(json.dumps(payload)) 472 | 473 | return new_order_id 474 | 475 | async def cancel_order(self, order_id): 476 | if not order_id: 477 | return 478 | 479 | payload = {"op": "cancel_order", "data": {"order_id": order_id}} 480 | logger.info(payload) 481 | await self.send(json.dumps(payload)) 482 | 483 | async def cancel_all_orders(self): 484 | payload = {"op": "cancel_all_orders", "data": {}} 485 | await self.send(json.dumps(payload)) 486 | 487 | def sign_order( 488 | self, 489 | instrument_id, 490 | is_buy, 491 | limit_price, 492 | quantity, 493 | timestamp, 494 | price_decimals=10**6, 495 | amount_decimals=10**6, 496 | ): 497 | salt = random.randint(0, 10**10) # We just need a large enough number 498 | 499 | order_struct = Order( 500 | maker=self.wallet_address, # The wallet"s main address 501 | isBuy=is_buy, 502 | limitPrice=int(round(limit_price * price_decimals, is_buy)), 503 | amount=int(round(quantity * amount_decimals, is_buy)), 504 | salt=salt, 505 | instrument=instrument_id, 506 | timestamp=timestamp, 507 | ) 508 | logger.info(self.signing_domain) 509 | domain = make_domain(**self.signing_domain) 510 | signable_bytes = keccak(order_struct.signable_bytes(domain=domain)) 511 | return ( 512 | salt, 513 | Account._sign_hash(signable_bytes, self.signing_key).signature.hex(), 514 | f"0x{signable_bytes.hex()}", 515 | ) -------------------------------------------------------------------------------- /aevo_market_price_trade.py: -------------------------------------------------------------------------------- 1 | # 策略2 : 2 | # - 查看买一卖一价差有多大,如果小于0.1%, 直接下市价单。 3 | # - 下单后,查询是否有未平仓位,如果有,市价平仓。 4 | # - 交易次数达到上限后,程序退出。 5 | 6 | import asyncio 7 | import json 8 | from aevo import AevoClient 9 | from dotenv import load_dotenv 10 | import os 11 | 12 | # 加载.env文件 13 | load_dotenv() 14 | 15 | 16 | 17 | async def main(): 18 | 19 | # ==================== 交易配置 ==================== 20 | tradeAsset = 'ETH' # 设置交易币种 21 | quantity = 0.1 # 设置每次交易数量(单位:币) 22 | max_trade_number = 50 # 设置刷交易的次数,开平仓为一次 23 | # =============================================== 24 | 25 | aevo = AevoClient( 26 | signing_key=os.getenv("SIGNING"), # 钱包私钥 27 | wallet_address=os.getenv("WALLETADDRESS"), # 钱包地址 28 | api_key=os.getenv("APIKEY"), # API key 29 | api_secret=os.getenv("APISECRET"), # API secret 30 | env="mainnet", 31 | ) 32 | 33 | 34 | if not aevo.signing_key: 35 | raise Exception( 36 | "Signing key is not set. Please set the signing key in the AevoClient constructor." 37 | ) 38 | 39 | markets = aevo.get_markets(tradeAsset) 40 | await aevo.open_connection() 41 | await aevo.subscribe_ticker(f"ticker:{tradeAsset}:PERPETUAL") 42 | number = 0 43 | async for msg in aevo.read_messages(): 44 | data = json.loads(msg)["data"] 45 | # 如果数据里包含ticker,就执行交易 46 | if "tickers" in data: 47 | print('开始执行第{}次交易'.format(number + 1)) 48 | instrument_id = markets[0]['instrument_id'] 49 | bid_price = float(data["tickers"][0]['bid']['price']) 50 | ask_price = float(data["tickers"][0]['ask']['price']) 51 | price_step = float(markets[0]['price_step']) 52 | price_decimals = len(str(price_step).split('.')[1]) 53 | # 计算价差比例 54 | spread = (ask_price - bid_price) / bid_price 55 | print(f'买一价:{bid_price}, 卖一价:{ask_price}, 价差比例:{spread}') 56 | if spread < 0.0005: 57 | print('价差比例小于0.05%,直接下市价单') 58 | # 下市价卖单 59 | response = aevo.rest_create_order(instrument_id=instrument_id, is_buy=False, limit_price=0, quantity=quantity, post_only=False) 60 | print(response) 61 | # 下市价买单 62 | buy_order_price = round( ask_price * 1.02, price_decimals) 63 | response = aevo.rest_create_order(instrument_id=instrument_id, is_buy=True, limit_price=buy_order_price, quantity=quantity, post_only=False) 64 | print(response) 65 | print('第{}次交易结束'.format(number),'开始查询是否有未平仓位。') 66 | account_info = aevo.rest_get_account() 67 | positions = account_info['positions'] 68 | if len(positions) > 0: 69 | # 找出instrument_name等于交易资产的position 70 | for position in positions: 71 | if position['instrument_name'] == f'{tradeAsset}-PERP': 72 | # 市价平仓 73 | instrument_id, cpquantity, side = position['instrument_id'], float(position['amount']), position['side'] 74 | is_buy = True if side == 'sell' else False 75 | limit_price = round( ask_price * 1.1, price_decimals) if is_buy else 0 76 | print(f'存在未平仓位,开始平仓,并取消所有挂单。instrument_id: {instrument_id}, quantity: {cpquantity}, is_buy: {is_buy}, limit_price: {limit_price}') 77 | response = aevo.rest_create_order(instrument_id=instrument_id, is_buy=is_buy, limit_price=limit_price, quantity=cpquantity, post_only=False) 78 | print(response) 79 | aevo.rest_cancel_all_orders() 80 | # 暂停5秒 81 | number += 1 82 | await asyncio.sleep(5) 83 | 84 | if number >= max_trade_number: 85 | print('交易次数已达到上限,程序退出') 86 | exit() 87 | 88 | if __name__ == "__main__": 89 | asyncio.run(main()) 90 | -------------------------------------------------------------------------------- /aevo_option_trade.py: -------------------------------------------------------------------------------- 1 | # 期权交易策略 : 2 | # - 查看买一卖一价差有多大,如果小于0.1%, 直接下市价单。 3 | # - 下单后,查询是否有未平仓位,如果有,市价平仓。 4 | # - 交易次数达到上限后,程序退出。 5 | 6 | import asyncio 7 | import json 8 | from aevo import AevoClient 9 | from dotenv import load_dotenv 10 | import os 11 | 12 | 13 | 14 | 15 | async def main(): 16 | # 加载.env文件 17 | load_dotenv() 18 | 19 | # ==================== 交易配置 ==================== 20 | quantity = 1 # 设置每次交易数量(单位:币) 21 | max_trade_number = 1000 # 设置刷交易的次数,开平仓为一次 22 | limit_price=0.3 # 订单价格,期权固定价格 23 | option_symbol = 'ETH-08MAR24-2650-P' # 期权合约名称 24 | # =============================================== 25 | 26 | 27 | aevo = AevoClient( 28 | signing_key=os.getenv("SIGNING"), # 钱包私钥 29 | wallet_address=os.getenv("WALLETADDRESS"), # 钱包地址 30 | api_key=os.getenv("APIKEY"), # API key 31 | api_secret=os.getenv("APISECRET"), # API secret 32 | env="mainnet", 33 | ) 34 | 35 | 36 | 37 | if not aevo.signing_key: 38 | raise Exception( 39 | "Signing key is not set. Please set the signing key in the AevoClient constructor." 40 | ) 41 | 42 | markets = aevo.get_markets(option_symbol) 43 | 44 | await aevo.open_connection() 45 | await aevo.subscribe_ticker(f"ticker:{option_symbol}") 46 | number = 0 47 | async for msg in aevo.read_messages(): 48 | data = json.loads(msg)["data"] 49 | # # 如果数据里包含ticker,就执行交易 50 | if "tickers" in data: 51 | print('开始执行第{}次交易'.format(number + 1)) 52 | instrument_id = data["tickers"][0]['instrument_id'] 53 | 54 | # 下卖单 55 | response = aevo.rest_create_order(instrument_id=instrument_id, is_buy=False, limit_price=limit_price, quantity=quantity, post_only=False) 56 | print(response) 57 | # 下买单 58 | response = aevo.rest_create_order(instrument_id=instrument_id, is_buy=True, limit_price=limit_price, quantity=quantity, post_only=False) 59 | print(response) 60 | 61 | # 暂停5秒 62 | number += 1 63 | # await asyncio.sleep(5) 64 | # aevo.rest_cancel_all_orders() 65 | 66 | if number >= max_trade_number: 67 | print('交易次数已达到上限,程序退出') 68 | exit() 69 | 70 | if __name__ == "__main__": 71 | asyncio.run(main()) 72 | -------------------------------------------------------------------------------- /aevo_trade.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | from aevo import AevoClient 4 | from dotenv import load_dotenv 5 | import os 6 | 7 | # 加载.env文件 8 | load_dotenv() 9 | 10 | 11 | 12 | async def main(): 13 | 14 | # ==================== 交易配置 ==================== 15 | tradeAsset = 'ETH' # 设置交易币种 16 | quantity = 0.01 # 设置每次交易数量(单位:币) 17 | max_trade_number = 20 # 设置刷交易的次数,开平仓为一次 18 | # =============================================== 19 | 20 | aevo = AevoClient( 21 | signing_key=os.getenv("SIGNING"), # 钱包私钥 22 | wallet_address=os.getenv("WALLETADDRESS"), # 钱包地址 23 | api_key=os.getenv("APIKEY"), # API key 24 | api_secret=os.getenv("APISECRET"), # API secret 25 | env="mainnet", 26 | ) 27 | 28 | 29 | if not aevo.signing_key: 30 | raise Exception( 31 | "Signing key is not set. Please set the signing key in the AevoClient constructor." 32 | ) 33 | 34 | markets = aevo.get_markets(tradeAsset) 35 | await aevo.open_connection() 36 | await aevo.subscribe_ticker(f"ticker:{tradeAsset}:PERPETUAL") 37 | number = 0 38 | async for msg in aevo.read_messages(): 39 | data = json.loads(msg)["data"] 40 | # 如果数据里包含ticker,就执行交易 41 | if "tickers" in data: 42 | print('开始执行第{}次交易'.format(number + 1)) 43 | bid_price = float(data["tickers"][0]['bid']['price']) 44 | ask_price = float(data["tickers"][0]['ask']['price']) 45 | price_step = float(markets[0]['price_step']) 46 | price_decimals = len(str(price_step).split('.')[1]) 47 | limit_price = round((bid_price + ask_price) / 2, price_decimals) 48 | instrument_id = markets[0]['instrument_id'] 49 | response = aevo.rest_create_order(instrument_id=instrument_id, is_buy=True, limit_price=limit_price, quantity=quantity, post_only=False) 50 | print(response) 51 | response = aevo.rest_create_order(instrument_id=instrument_id, is_buy=False, limit_price=limit_price, quantity=quantity, post_only=False) 52 | print(response) 53 | number += 1 54 | 55 | print('第{}次交易结束'.format(number),'开始查询是否有未平仓位。') 56 | account_info = aevo.rest_get_account() 57 | positions = account_info['positions'] 58 | if len(positions) > 0: 59 | # 找出instrument_name等于交易资产的position 60 | for position in positions: 61 | if position['instrument_name'] == f'{tradeAsset}-PERP': 62 | # 市价平仓 63 | instrument_id, quantity, side = position['instrument_id'], float(position['amount']), position['side'] 64 | is_buy = True if side == 'sell' else False 65 | limit_price = 2**200 - 1 if is_buy else 0 66 | print(f'存在未平仓位,开始平仓,并取消所有挂单。instrument_id: {instrument_id}, quantity: {quantity}, is_buy: {is_buy}, limit_price: {limit_price}') 67 | response = aevo.rest_create_order(instrument_id=instrument_id, is_buy=False, limit_price=limit_price, quantity=quantity, post_only=False) 68 | aevo.rest_cancel_all_orders() 69 | # 暂停5秒 70 | await asyncio.sleep(5) 71 | 72 | if number >= max_trade_number: 73 | print('交易次数已达到上限,程序退出') 74 | exit() 75 | 76 | if __name__ == "__main__": 77 | asyncio.run(main()) 78 | -------------------------------------------------------------------------------- /create_apiKey.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | from typing import TypedDict 3 | import requests 4 | from eth_account import Account 5 | from eth_hash.auto import keccak as keccak_256 6 | from eip712_structs import Address, EIP712Struct, Uint, make_domain 7 | import pandas as pd 8 | 9 | 10 | class Register(EIP712Struct): 11 | key = Address() 12 | expiry = Uint(256) 13 | 14 | 15 | class SignKey(EIP712Struct): 16 | account = Address() 17 | 18 | 19 | class AevoRegister(TypedDict): 20 | account: str 21 | signing_key: str 22 | expiry: str 23 | account_signature: str 24 | signing_key_signature: str 25 | 26 | 27 | CONFIG = { 28 | "testnet": { 29 | "rest_url": "https://api-testnet.aevo.xyz", 30 | "ws_url": "wss://ws-testnet.aevo.xyz", 31 | "signing_domain": { 32 | "name": "Aevo Testnet", 33 | "version": "1", 34 | "chainId": "11155111", 35 | }, 36 | }, 37 | "mainnet": { 38 | "rest_url": "https://api.aevo.xyz", 39 | "ws_url": "wss://ws.aevo.xyz", 40 | "signing_domain": { 41 | "name": "Aevo Mainnet", 42 | "version": "1", 43 | "chainId": "1", 44 | }, 45 | }, 46 | } 47 | 48 | def generate_api_info(account_key, environment): 49 | domain = make_domain(**CONFIG[environment]["signing_domain"]) 50 | 51 | account = Account.from_key(account_key) 52 | signing_key = secrets.token_hex(32) 53 | signing_key_account = Account.from_key(signing_key) 54 | 55 | expiry = 2**256 - 1 56 | 57 | sign_key = SignKey(account=account.address) 58 | register = Register(key=signing_key_account.address, expiry=expiry) 59 | 60 | sign_key_hash = keccak_256(sign_key.signable_bytes(domain=domain)) 61 | signing_key_signature = Account._sign_hash(sign_key_hash, signing_key).signature.hex() 62 | 63 | register_hash = keccak_256(register.signable_bytes(domain=domain)) 64 | account_signature = Account._sign_hash(register_hash, account_key).signature.hex() 65 | 66 | aevo_register: AevoRegister = { 67 | "account": account.address, 68 | "signing_key": signing_key_account.address, 69 | "expiry": str(expiry), 70 | "account_signature": account_signature, 71 | "signing_key_signature": signing_key_signature, 72 | } 73 | 74 | r = requests.post(f"{CONFIG[environment]['rest_url']}/register", json=aevo_register) 75 | j = r.json() 76 | 77 | api_info = { 78 | 'signing_key': account_key, 79 | 'wallet_address': account.address, 80 | 'api_key': j['api_key'], 81 | 'api_secret': j['api_secret'], 82 | 'env': environment 83 | } 84 | return api_info 85 | 86 | if __name__ == "__main__": 87 | input_path = '/Users/wr/Desktop/BOT/aevoTrading/data/input/aevoAccount.csv' # 输入钱包私钥的路径 88 | output_path = '/Users/wr/Desktop/BOT/aevoTrading/data/output/api_keys.csv' # 保存api_key的路径 89 | df = pd.read_csv(input_path) 90 | environment = "mainnet" 91 | apis = [] 92 | # 遍历每一行 93 | for index, row in df.iterrows(): 94 | account_key = row['PrivateKey'] 95 | api_info = generate_api_info(account_key, environment) 96 | apis.append(api_info) 97 | df = pd.DataFrame(apis) 98 | df.to_csv(output_path, index=False) -------------------------------------------------------------------------------- /data/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shuail0/aevoTrading/4f0890bf65be260fdbda9291fd0ffcdb8af7582c/data/.DS_Store -------------------------------------------------------------------------------- /data/input/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shuail0/aevoTrading/4f0890bf65be260fdbda9291fd0ffcdb8af7582c/data/input/.DS_Store -------------------------------------------------------------------------------- /data/input/aevoAccount.csv: -------------------------------------------------------------------------------- 1 | Wallet,Address,PrivateKey 2 | testWallet,0x0000,0x0000 -------------------------------------------------------------------------------- /eip712_structs.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is basically the code from here https://github.com/ConsenSysMesh/py-eip712-structs merged into 1 file 3 | 4 | The reason this is done is because we want to use Python3.11+ and the library only supports 3.6 5 | The library requires pysha library which can not be installed on 3.11 6 | """ 7 | import functools 8 | import json 9 | import operator 10 | import re 11 | from collections import OrderedDict, defaultdict 12 | from json import JSONEncoder 13 | from typing import Any, List, NamedTuple, Tuple, Type, Union 14 | 15 | from eth_utils.conversions import to_bytes, to_hex, to_int 16 | from eth_utils.crypto import keccak 17 | 18 | default_domain = None 19 | 20 | 21 | class EIP712Type: 22 | """The base type for members of a struct. 23 | 24 | Generally you wouldn't use this - instead, see the subclasses below. Or you may want an EIP712Struct instead. 25 | """ 26 | 27 | def __init__(self, type_name: str, none_val: Any): 28 | self.type_name = type_name 29 | self.none_val = none_val 30 | 31 | def encode_value(self, value) -> bytes: 32 | """Given a value, verify it and convert into the format required by the spec. 33 | 34 | :param value: A correct input value for the implemented type. 35 | :return: A 32-byte object containing encoded data 36 | """ 37 | if value is None: 38 | return self._encode_value(self.none_val) 39 | else: 40 | return self._encode_value(value) 41 | 42 | def _encode_value(self, value) -> bytes: 43 | """Must be implemented by subclasses, handles value encoding on a case-by-case basis. 44 | 45 | Don't call this directly - use ``.encode_value(value)`` instead. 46 | """ 47 | pass 48 | 49 | def __eq__(self, other): 50 | self_type = getattr(self, "type_name") 51 | other_type = getattr(other, "type_name") 52 | 53 | return self_type is not None and self_type == other_type 54 | 55 | def __hash__(self): 56 | return hash(self.type_name) 57 | 58 | 59 | class Array(EIP712Type): 60 | def __init__( 61 | self, member_type: Union[EIP712Type, Type[EIP712Type]], fixed_length: int = 0 62 | ): 63 | """Represents an array member type. 64 | 65 | Example: 66 | a1 = Array(String()) # string[] a1 67 | a2 = Array(String(), 8) # string[8] a2 68 | a3 = Array(MyStruct) # MyStruct[] a3 69 | """ 70 | fixed_length = int(fixed_length) 71 | if fixed_length == 0: 72 | type_name = f"{member_type.type_name}[]" 73 | else: 74 | type_name = f"{member_type.type_name}[{fixed_length}]" 75 | self.member_type = member_type 76 | self.fixed_length = fixed_length 77 | super(Array, self).__init__(type_name, []) 78 | 79 | def _encode_value(self, value): 80 | """Arrays are encoded by concatenating their encoded contents, and taking the keccak256 hash.""" 81 | encoder = self.member_type 82 | encoded_values = [encoder.encode_value(v) for v in value] 83 | return keccak(b"".join(encoded_values)) 84 | 85 | 86 | class Address(EIP712Type): 87 | def __init__(self): 88 | """Represents an ``address`` type.""" 89 | super(Address, self).__init__("address", 0) 90 | 91 | def _encode_value(self, value): 92 | """Addresses are encoded like Uint160 numbers.""" 93 | 94 | # Some smart conversions - need to get the address to a numeric before we encode it 95 | if isinstance(value, bytes): 96 | v = to_int(value) 97 | elif isinstance(value, str): 98 | v = to_int(hexstr=value) 99 | else: 100 | v = value # Fallback, just use it as-is. 101 | return Uint(160).encode_value(v) 102 | 103 | 104 | class Boolean(EIP712Type): 105 | def __init__(self): 106 | """Represents a ``bool`` type.""" 107 | super(Boolean, self).__init__("bool", False) 108 | 109 | def _encode_value(self, value): 110 | """Booleans are encoded like the uint256 values of 0 and 1.""" 111 | if value is False: 112 | return Uint(256).encode_value(0) 113 | elif value is True: 114 | return Uint(256).encode_value(1) 115 | else: 116 | raise ValueError(f"Must be True or False. Got: {value}") 117 | 118 | 119 | class Bytes(EIP712Type): 120 | def __init__(self, length: int = 0): 121 | """Represents a solidity bytes type. 122 | 123 | Length may be used to specify a static ``bytesN`` type. Or 0 for a dynamic ``bytes`` type. 124 | Example: 125 | b1 = Bytes() # bytes b1 126 | b2 = Bytes(10) # bytes10 b2 127 | 128 | ``length`` MUST be between 0 and 32, or a ValueError is raised. 129 | """ 130 | length = int(length) 131 | if length == 0: 132 | # Special case: Length of 0 means a dynamic bytes type 133 | type_name = "bytes" 134 | elif 1 <= length <= 32: 135 | type_name = f"bytes{length}" 136 | else: 137 | raise ValueError(f"Byte length must be between 1 or 32. Got: {length}") 138 | self.length = length 139 | super(Bytes, self).__init__(type_name, b"") 140 | 141 | def _encode_value(self, value): 142 | """Static bytesN types are encoded by right-padding to 32 bytes. Dynamic bytes types are keccak256 hashed.""" 143 | if isinstance(value, str): 144 | # Try converting to a bytestring, assuming that it's been given as hex 145 | value = to_bytes(hexstr=value) 146 | 147 | if self.length == 0: 148 | return keccak(value) 149 | else: 150 | if len(value) > self.length: 151 | raise ValueError( 152 | f"{self.type_name} was given bytes with length {len(value)}" 153 | ) 154 | padding = bytes(32 - len(value)) 155 | return value + padding 156 | 157 | 158 | class Int(EIP712Type): 159 | def __init__(self, length: int = 256): 160 | """Represents a signed int type. Length may be given to specify the int length in bits. Default length is 256 161 | 162 | Example: 163 | i1 = Int(256) # int256 i1 164 | i2 = Int() # int256 i2 165 | i3 = Int(128) # int128 i3 166 | """ 167 | length = int(length) 168 | if length < 8 or length > 256 or length % 8 != 0: 169 | raise ValueError( 170 | f"Int length must be a multiple of 8, between 8 and 256. Got: {length}" 171 | ) 172 | self.length = length 173 | super(Int, self).__init__(f"int{length}", 0) 174 | 175 | def _encode_value(self, value: int): 176 | """Ints are encoded by padding them to 256-bit representations.""" 177 | value.to_bytes(self.length // 8, byteorder="big", signed=True) # For validation 178 | return value.to_bytes(32, byteorder="big", signed=True) 179 | 180 | 181 | class String(EIP712Type): 182 | def __init__(self): 183 | """Represents a string type.""" 184 | super(String, self).__init__("string", "") 185 | 186 | def _encode_value(self, value): 187 | """Strings are encoded by taking the keccak256 hash of their contents.""" 188 | return keccak(text=value) 189 | 190 | 191 | class Uint(EIP712Type): 192 | def __init__(self, length: int = 256): 193 | """Represents an unsigned int type. Length may be given to specify the int length in bits. Default length is 256 194 | 195 | Example: 196 | ui1 = Uint(256) # uint256 ui1 197 | ui2 = Uint() # uint256 ui2 198 | ui3 = Uint(128) # uint128 ui3 199 | """ 200 | length = int(length) 201 | if length < 8 or length > 256 or length % 8 != 0: 202 | raise ValueError( 203 | f"Uint length must be a multiple of 8, between 8 and 256. Got: {length}" 204 | ) 205 | self.length = length 206 | super(Uint, self).__init__(f"uint{length}", 0) 207 | 208 | def _encode_value(self, value: int): 209 | """Uints are encoded by padding them to 256-bit representations.""" 210 | value.to_bytes( 211 | self.length // 8, byteorder="big", signed=False 212 | ) # For validation 213 | return value.to_bytes(32, byteorder="big", signed=False) 214 | 215 | 216 | # This helper dict maps solidity's type names to our EIP712Type classes 217 | solidity_type_map = { 218 | "address": Address, 219 | "bool": Boolean, 220 | "bytes": Bytes, 221 | "int": Int, 222 | "string": String, 223 | "uint": Uint, 224 | } 225 | 226 | 227 | def from_solidity_type(solidity_type: str): 228 | """Convert a string into the EIP712Type implementation. Basic types only.""" 229 | pattern = r"([a-z]+)(\d+)?(\[(\d+)?\])?" 230 | match = re.match(pattern, solidity_type) 231 | 232 | if match is None: 233 | return None 234 | 235 | type_name = match.group(1) # The type name, like the "bytes" in "bytes32" 236 | opt_len = match.group(2) # An optional length spec, like the "32" in "bytes32" 237 | is_array = match.group(3) # Basically just checks for square brackets 238 | array_len = match.group(4) # For fixed length arrays only, this is the length 239 | 240 | if type_name not in solidity_type_map: 241 | # Only supporting basic types here - return None if we don't recognize it. 242 | return None 243 | 244 | # Construct the basic type 245 | base_type = solidity_type_map[type_name] 246 | if opt_len: 247 | type_instance = base_type(int(opt_len)) 248 | else: 249 | type_instance = base_type() 250 | 251 | if is_array: 252 | # Nest the aforementioned basic type into an Array. 253 | if array_len: 254 | result = Array(type_instance, int(array_len)) 255 | else: 256 | result = Array(type_instance) 257 | return result 258 | else: 259 | return type_instance 260 | 261 | 262 | class OrderedAttributesMeta(type): 263 | """Metaclass to ensure struct attribute order is preserved.""" 264 | 265 | @classmethod 266 | def __prepare__(mcs, name, bases): 267 | return OrderedDict() 268 | 269 | 270 | class EIP712Struct(EIP712Type, metaclass=OrderedAttributesMeta): 271 | """A representation of an EIP712 struct. Subclass it to use it. 272 | 273 | Example: 274 | from eip712_structs import EIP712Struct, String 275 | 276 | class MyStruct(EIP712Struct): 277 | some_param = String() 278 | 279 | struct_instance = MyStruct(some_param='some_value') 280 | """ 281 | 282 | def __init__(self, **kwargs): 283 | super(EIP712Struct, self).__init__(self.type_name, None) 284 | members = self.get_members() 285 | self.values = dict() 286 | for name, typ in members: 287 | value = kwargs.get(name) 288 | if isinstance(value, dict): 289 | value = typ(**value) 290 | self.values[name] = value 291 | 292 | @classmethod 293 | def __init_subclass__(cls, **kwargs): 294 | super().__init_subclass__(**kwargs) 295 | cls.type_name = cls.__name__ 296 | 297 | def encode_value(self, value=None): 298 | """Returns the struct's encoded value. 299 | 300 | A struct's encoded value is a concatenation of the bytes32 representation of each member of the struct. 301 | Order is preserved. 302 | 303 | :param value: This parameter is not used for structs. 304 | """ 305 | encoded_values = list() 306 | for name, typ in self.get_members(): 307 | if isinstance(typ, type) and issubclass(typ, EIP712Struct): 308 | # Nested structs are recursively hashed, with the resulting 32-byte hash appended to the list of values 309 | sub_struct = self.get_data_value(name) 310 | encoded_values.append(sub_struct.hash_struct()) 311 | else: 312 | # Regular types are encoded as normal 313 | encoded_values.append(typ.encode_value(self.values[name])) 314 | return b"".join(encoded_values) 315 | 316 | def get_data_value(self, name): 317 | """Get the value of the given struct parameter.""" 318 | return self.values.get(name) 319 | 320 | def set_data_value(self, name, value): 321 | """Set the value of the given struct parameter.""" 322 | if name in self.values: 323 | self.values[name] = value 324 | 325 | def data_dict(self): 326 | """Provide the entire data dictionary representing the struct. 327 | 328 | Nested structs instances are also converted to dict form. 329 | """ 330 | result = dict() 331 | for k, v in self.values.items(): 332 | if isinstance(v, EIP712Struct): 333 | result[k] = v.data_dict() 334 | else: 335 | result[k] = v 336 | return result 337 | 338 | @classmethod 339 | def _encode_type(cls, resolve_references: bool) -> str: 340 | member_sigs = [f"{typ.type_name} {name}" for name, typ in cls.get_members()] 341 | struct_sig = f'{cls.type_name}({",".join(member_sigs)})' 342 | 343 | if resolve_references: 344 | reference_structs = set() 345 | cls._gather_reference_structs(reference_structs) 346 | sorted_structs = sorted( 347 | list(s for s in reference_structs if s != cls), 348 | key=lambda s: s.type_name, 349 | ) 350 | for struct in sorted_structs: 351 | struct_sig += struct._encode_type(resolve_references=False) 352 | return struct_sig 353 | 354 | @classmethod 355 | def _gather_reference_structs(cls, struct_set): 356 | """Finds reference structs defined in this struct type, and inserts them into the given set.""" 357 | structs = [ 358 | m[1] 359 | for m in cls.get_members() 360 | if isinstance(m[1], type) and issubclass(m[1], EIP712Struct) 361 | ] 362 | for struct in structs: 363 | if struct not in struct_set: 364 | struct_set.add(struct) 365 | struct._gather_reference_structs(struct_set) 366 | 367 | @classmethod 368 | def encode_type(cls): 369 | """Get the encoded type signature of the struct. 370 | 371 | Nested structs are also encoded, and appended in alphabetical order. 372 | """ 373 | return cls._encode_type(True) 374 | 375 | @classmethod 376 | def type_hash(cls) -> bytes: 377 | """Get the keccak hash of the struct's encoded type.""" 378 | return keccak(text=cls.encode_type()) 379 | 380 | def hash_struct(self) -> bytes: 381 | """The hash of the struct. 382 | 383 | hash_struct => keccak(type_hash || encode_data) 384 | """ 385 | return keccak(b"".join([self.type_hash(), self.encode_value()])) 386 | 387 | @classmethod 388 | def get_members(cls) -> List[Tuple[str, EIP712Type]]: 389 | """A list of tuples of supported parameters. 390 | 391 | Each tuple is (, ). The list's order is determined by definition order. 392 | """ 393 | members = [ 394 | m 395 | for m in cls.__dict__.items() 396 | if isinstance(m[1], EIP712Type) 397 | or (isinstance(m[1], type) and issubclass(m[1], EIP712Struct)) 398 | ] 399 | return members 400 | 401 | @staticmethod 402 | def _assert_domain(domain): 403 | result = domain or eip712_structs.default_domain 404 | if not result: 405 | raise ValueError( 406 | "Domain must be provided, or eip712_structs.default_domain must be set." 407 | ) 408 | return result 409 | 410 | def to_message(self, domain: "EIP712Struct" = None) -> dict: 411 | """Convert a struct into a dictionary suitable for messaging. 412 | 413 | Dictionary is of the form: 414 | { 415 | 'primaryType': Name of the primary type, 416 | 'types': Definition of each included struct type (including the domain type) 417 | 'domain': Values for the domain struct, 418 | 'message': Values for the message struct, 419 | } 420 | 421 | :returns: This struct + the domain in dict form, structured as specified for EIP712 messages. 422 | """ 423 | domain = self._assert_domain(domain) 424 | structs = {domain, self} 425 | self._gather_reference_structs(structs) 426 | 427 | # Build type dictionary 428 | types = dict() 429 | for struct in structs: 430 | members_json = [ 431 | { 432 | "name": m[0], 433 | "type": m[1].type_name, 434 | } 435 | for m in struct.get_members() 436 | ] 437 | types[struct.type_name] = members_json 438 | 439 | result = { 440 | "primaryType": self.type_name, 441 | "types": types, 442 | "domain": domain.data_dict(), 443 | "message": self.data_dict(), 444 | } 445 | 446 | return result 447 | 448 | def to_message_json(self, domain: "EIP712Struct" = None) -> str: 449 | message = self.to_message(domain) 450 | return json.dumps(message, cls=BytesJSONEncoder) 451 | 452 | def signable_bytes(self, domain: "EIP712Struct" = None) -> bytes: 453 | """Return a ``bytes`` object suitable for signing, as specified for EIP712. 454 | 455 | As per the spec, bytes are constructed as follows: 456 | ``b'\x19\x01' + domain_hash_bytes + struct_hash_bytes`` 457 | 458 | :param domain: The domain to include in the hash bytes. If None, uses ``eip712_structs.default_domain`` 459 | :return: The bytes object 460 | """ 461 | domain = self._assert_domain(domain) 462 | result = b"\x19\x01" + domain.hash_struct() + self.hash_struct() 463 | return result 464 | 465 | @classmethod 466 | def from_message(cls, message_dict: dict) -> "StructTuple": 467 | """Convert a message dictionary into two EIP712Struct objects - one for domain, another for the message struct. 468 | 469 | Returned as a StructTuple, which has the attributes ``message`` and ``domain``. 470 | 471 | Example: 472 | my_msg = { .. } 473 | deserialized = EIP712Struct.from_message(my_msg) 474 | msg_struct = deserialized.message 475 | domain_struct = deserialized.domain 476 | 477 | :param message_dict: The dictionary, such as what is produced by EIP712Struct.to_message. 478 | :return: A StructTuple object, containing the message and domain structs. 479 | """ 480 | structs = dict() 481 | unfulfilled_struct_params = defaultdict(list) 482 | 483 | for type_name in message_dict["types"]: 484 | # Dynamically construct struct class from dict representation 485 | StructFromJSON = type(type_name, (EIP712Struct,), {}) 486 | 487 | for member in message_dict["types"][type_name]: 488 | # Either a basic solidity type is set, or None if referring to a reference struct (we'll fill it later) 489 | member_name = member["name"] 490 | member_sol_type = from_solidity_type(member["type"]) 491 | setattr(StructFromJSON, member_name, member_sol_type) 492 | if member_sol_type is None: 493 | # Track the refs we'll need to set later. 494 | unfulfilled_struct_params[type_name].append( 495 | (member_name, member["type"]) 496 | ) 497 | 498 | structs[type_name] = StructFromJSON 499 | 500 | # Now that custom structs have been parsed, pass through again to set the references 501 | for struct_name, unfulfilled_member_names in unfulfilled_struct_params.items(): 502 | regex_pattern = r"([a-zA-Z0-9_]+)(\[(\d+)?\])?" 503 | 504 | struct_class = structs[struct_name] 505 | for name, type_name in unfulfilled_member_names: 506 | match = re.match(regex_pattern, type_name) 507 | base_type_name = match.group(1) 508 | ref_struct = structs[base_type_name] 509 | if match.group(2): 510 | # The type is an array of the struct 511 | arr_len = ( 512 | match.group(3) or 0 513 | ) # length of 0 means the array is dynamically sized 514 | setattr(struct_class, name, Array(ref_struct, arr_len)) 515 | else: 516 | setattr(struct_class, name, ref_struct) 517 | 518 | primary_struct = structs[message_dict["primaryType"]] 519 | domain_struct = structs["EIP712Domain"] 520 | 521 | primary_result = primary_struct(**message_dict["message"]) 522 | domain_result = domain_struct(**message_dict["domain"]) 523 | result = StructTuple(message=primary_result, domain=domain_result) 524 | 525 | return result 526 | 527 | @classmethod 528 | def _assert_key_is_member(cls, key): 529 | member_names = {tup[0] for tup in cls.get_members()} 530 | if key not in member_names: 531 | raise KeyError(f'"{key}" is not defined for this struct.') 532 | 533 | @classmethod 534 | def _assert_property_type(cls, key, value): 535 | """Eagerly check for a correct member type""" 536 | members = dict(cls.get_members()) 537 | typ = members[key] 538 | 539 | if isinstance(typ, type) and issubclass(typ, EIP712Struct): 540 | # We expect an EIP712Struct instance. Assert that's true, and check the struct signature too. 541 | if not isinstance(value, EIP712Struct) or value._encode_type( 542 | False 543 | ) != typ._encode_type(False): 544 | raise ValueError( 545 | f"Given value is of type {type(value)}, but we expected {typ}" 546 | ) 547 | else: 548 | # Since it isn't a nested struct, its an EIP712Type 549 | try: 550 | typ.encode_value(value) 551 | except Exception as e: 552 | raise ValueError( 553 | f"The python type {type(value)} does not appear " 554 | f"to be supported for data type {typ}." 555 | ) from e 556 | 557 | def __getitem__(self, key): 558 | """Provide access directly to the underlying value dictionary""" 559 | self._assert_key_is_member(key) 560 | return self.values.__getitem__(key) 561 | 562 | def __setitem__(self, key, value): 563 | """Provide access directly to the underlying value dictionary""" 564 | self._assert_key_is_member(key) 565 | self._assert_property_type(key, value) 566 | 567 | return self.values.__setitem__(key, value) 568 | 569 | def __delitem__(self, _): 570 | raise TypeError("Deleting entries from an EIP712Struct is not allowed.") 571 | 572 | def __eq__(self, other): 573 | if not other: 574 | # Null check 575 | return False 576 | if self is other: 577 | # Check identity 578 | return True 579 | if not isinstance(other, EIP712Struct): 580 | # Check class 581 | return False 582 | # Our structs are considered equal if their type signature and encoded value signature match. 583 | # E.g., like computing signable bytes but without a domain separator 584 | return ( 585 | self.encode_type() == other.encode_type() 586 | and self.encode_value() == other.encode_value() 587 | ) 588 | 589 | def __hash__(self): 590 | value_hashes = [hash(k) ^ hash(v) for k, v in self.values.items()] 591 | return functools.reduce(operator.xor, value_hashes, hash(self.type_name)) 592 | 593 | 594 | class StructTuple(NamedTuple): 595 | message: EIP712Struct 596 | domain: EIP712Struct 597 | 598 | 599 | class BytesJSONEncoder(JSONEncoder): 600 | def default(self, o): 601 | if isinstance(o, bytes): 602 | return to_hex(o) 603 | else: 604 | return super(BytesJSONEncoder, self).default(o) 605 | 606 | 607 | def make_domain( 608 | name=None, version=None, chainId=None, verifyingContract=None, salt=None 609 | ): 610 | """Helper method to create the standard EIP712Domain struct for you. 611 | 612 | Per the standard, if a value is not used then the parameter is omitted from the struct entirely. 613 | """ 614 | 615 | if all(i is None for i in [name, version, chainId, verifyingContract, salt]): 616 | raise ValueError("At least one argument must be given.") 617 | 618 | class EIP712Domain(EIP712Struct): 619 | pass 620 | 621 | kwargs = dict() 622 | if name is not None: 623 | EIP712Domain.name = String() 624 | kwargs["name"] = str(name) 625 | if version is not None: 626 | EIP712Domain.version = String() 627 | kwargs["version"] = str(version) 628 | if chainId is not None: 629 | EIP712Domain.chainId = Uint(256) 630 | kwargs["chainId"] = int(chainId) 631 | if verifyingContract is not None: 632 | EIP712Domain.verifyingContract = Address() 633 | kwargs["verifyingContract"] = verifyingContract 634 | if salt is not None: 635 | EIP712Domain.salt = Bytes(32) 636 | kwargs["salt"] = salt 637 | 638 | return EIP712Domain(**kwargs) 639 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.9.1 2 | aiosignal==1.3.1 3 | attrs==23.1.0 4 | bitarray==2.8.5 5 | certifi==2023.11.17 6 | charset-normalizer==3.3.2 7 | cytoolz==0.12.2 8 | eth-abi==4.2.1 9 | eth-account==0.10.0 10 | eth-hash==0.5.2 11 | eth-keyfile==0.7.0 12 | eth-keys==0.4.0 13 | eth-rlp==1.0.0 14 | eth-typing==3.5.2 15 | eth-utils==2.3.1 16 | frozenlist==1.4.0 17 | hexbytes==0.3.1 18 | idna==3.6 19 | jsonschema==4.20.0 20 | jsonschema-specifications==2023.11.2 21 | loguru==0.7.2 22 | lru-dict==1.2.0 23 | multidict==6.0.4 24 | parsimonious==0.9.0 25 | protobuf==4.25.1 26 | pycryptodome==3.19.0 27 | pyunormalize==15.1.0 28 | referencing==0.32.0 29 | regex==2023.10.3 30 | requests==2.31.0 31 | rlp==4.0.0 32 | rpds-py==0.13.2 33 | toolz==0.12.0 34 | typing_extensions==4.9.0 35 | urllib3==2.1.0 36 | web3==6.12.0 37 | websockets==12.0 38 | yarl==1.9.4 39 | python-dotenv==1.0.1 40 | --------------------------------------------------------------------------------