afp-sdk 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- afp/__init__.py +10 -0
- afp/api/__init__.py +0 -0
- afp/api/admin.py +55 -0
- afp/api/base.py +76 -0
- afp/api/builder.py +201 -0
- afp/api/clearing.py +377 -0
- afp/api/liquidation.py +167 -0
- afp/api/trading.py +342 -0
- afp/bindings/__init__.py +48 -0
- afp/bindings/auctioneer_facet.py +758 -0
- afp/bindings/bankruptcy_facet.py +355 -0
- afp/bindings/clearing_facet.py +1019 -0
- afp/bindings/erc20.py +379 -0
- afp/bindings/facade.py +87 -0
- afp/bindings/final_settlement_facet.py +268 -0
- afp/bindings/margin_account.py +1355 -0
- afp/bindings/margin_account_registry.py +617 -0
- afp/bindings/mark_price_tracker_facet.py +111 -0
- afp/bindings/oracle_provider.py +539 -0
- afp/bindings/product_registry.py +1302 -0
- afp/bindings/trading_protocol.py +1181 -0
- afp/config.py +38 -0
- afp/decorators.py +74 -0
- afp/enums.py +27 -0
- afp/exceptions.py +26 -0
- afp/exchange.py +151 -0
- afp/py.typed +0 -0
- afp/schemas.py +207 -0
- afp/signing.py +69 -0
- afp/validators.py +43 -0
- afp_sdk-0.1.0.dist-info/METADATA +180 -0
- afp_sdk-0.1.0.dist-info/RECORD +34 -0
- afp_sdk-0.1.0.dist-info/WHEEL +4 -0
- afp_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
afp/config.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from web3 import Web3
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# Constants from clearing/contracts/lib/constants.sol
|
|
7
|
+
RATE_MULTIPLIER = 10**4
|
|
8
|
+
FEE_RATE_MULTIPLIER = 10**6
|
|
9
|
+
FULL_PRECISION_MULTIPLIER = 10**18
|
|
10
|
+
|
|
11
|
+
USER_AGENT = "afp-sdk"
|
|
12
|
+
EXCHANGE_URL = os.getenv(
|
|
13
|
+
"AFP_EXCHANGE_URL", "https://afp-exchange-staging.up.railway.app"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
CHAIN_ID = int(os.getenv("AFP_CHAIN_ID", 65010004))
|
|
17
|
+
|
|
18
|
+
CLEARING_DIAMOND_ADDRESS = Web3.to_checksum_address(
|
|
19
|
+
os.getenv(
|
|
20
|
+
"AFP_CLEARING_DIAMOND_ADDRESS", "0xB894bFf368Bf1EA89c18612670B7E072866a5264"
|
|
21
|
+
)
|
|
22
|
+
)
|
|
23
|
+
MARGIN_ACCOUNT_REGISTRY_ADDRESS = Web3.to_checksum_address(
|
|
24
|
+
os.getenv(
|
|
25
|
+
"AFP_MARGIN_ACCOUNT_REGISTRY_ADDRESS",
|
|
26
|
+
"0xDA71FdE0E7cfFf445e848EAdB3B2255B68Ed6ef6",
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
ORACLE_PROVIDER_ADDRESS = Web3.to_checksum_address(
|
|
30
|
+
os.getenv(
|
|
31
|
+
"AFP_ORACLE_PROVIDER_ADDRESS", "0x626da921088a5A00C75C208Decbb60E816488481"
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
PRODUCT_REGISTRY_ADDRESS = Web3.to_checksum_address(
|
|
35
|
+
os.getenv(
|
|
36
|
+
"AFP_PRODUCT_REGISTRY_ADDRESS", "0x9b92EAC112c996a513e515Cf8c3BDd83619F730D"
|
|
37
|
+
)
|
|
38
|
+
)
|
afp/decorators.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from itertools import chain
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import inflection
|
|
6
|
+
from decorator import decorator
|
|
7
|
+
from eth_typing.abi import ABI
|
|
8
|
+
from eth_utils import abi
|
|
9
|
+
from hexbytes import HexBytes
|
|
10
|
+
from web3.exceptions import (
|
|
11
|
+
ContractLogicError,
|
|
12
|
+
ContractCustomError,
|
|
13
|
+
Web3Exception,
|
|
14
|
+
Web3RPCError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from .api.base import ExchangeAPI
|
|
18
|
+
from .exceptions import ClearingSystemError, AuthenticationError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@decorator
|
|
22
|
+
def refresh_token_on_expiry(
|
|
23
|
+
f: Callable[..., Any], *args: Any, **kwargs: Any
|
|
24
|
+
) -> Callable[..., Any]:
|
|
25
|
+
exchange_api = args[0]
|
|
26
|
+
assert isinstance(exchange_api, ExchangeAPI)
|
|
27
|
+
try:
|
|
28
|
+
return f(*args, **kwargs)
|
|
29
|
+
except AuthenticationError:
|
|
30
|
+
exchange_api._login() # type: ignore
|
|
31
|
+
return f(*args, **kwargs)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def convert_web3_error(*contract_abis: ABI) -> Callable[..., Any]:
|
|
35
|
+
def caller(f: Callable[..., Any], *args: Any, **kwargs: Any) -> Callable[..., Any]:
|
|
36
|
+
try:
|
|
37
|
+
return f(*args, **kwargs)
|
|
38
|
+
except ContractLogicError as contract_error:
|
|
39
|
+
reason = None
|
|
40
|
+
if contract_error.data:
|
|
41
|
+
reason = str(contract_error.data)
|
|
42
|
+
if isinstance(contract_error, ContractCustomError):
|
|
43
|
+
reason = _decode_custom_error(
|
|
44
|
+
str(contract_error.data), *contract_abis
|
|
45
|
+
)
|
|
46
|
+
if reason == "no data":
|
|
47
|
+
reason = "Unspecified reason"
|
|
48
|
+
raise ClearingSystemError(
|
|
49
|
+
"Contract call reverted" + f": {reason}" if reason else ""
|
|
50
|
+
) from contract_error
|
|
51
|
+
except Web3RPCError as rpc_error:
|
|
52
|
+
reason = None
|
|
53
|
+
if rpc_error.rpc_response:
|
|
54
|
+
reason = rpc_error.rpc_response.get("error", {}).get("message")
|
|
55
|
+
raise ClearingSystemError(
|
|
56
|
+
"Blockchain returned error" + f": {reason}" if reason else ""
|
|
57
|
+
) from rpc_error
|
|
58
|
+
except Web3Exception as web3_exception:
|
|
59
|
+
raise ClearingSystemError(str(web3_exception)) from web3_exception
|
|
60
|
+
|
|
61
|
+
return decorator(caller)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _decode_custom_error(data: str, *contract_abis: ABI) -> str | None:
|
|
65
|
+
selector = HexBytes(data)[:4]
|
|
66
|
+
combined_abi = list(chain(*contract_abis))
|
|
67
|
+
for error_candidate in abi.filter_abi_by_type("error", combined_abi):
|
|
68
|
+
signature = abi.abi_to_signature(error_candidate)
|
|
69
|
+
selector_candidate = abi.function_signature_to_4byte_selector(signature)
|
|
70
|
+
if selector == selector_candidate:
|
|
71
|
+
error = error_candidate["name"]
|
|
72
|
+
# Convert 'ErrorType' to 'Error type'
|
|
73
|
+
return inflection.humanize(inflection.underscore(error))
|
|
74
|
+
return None
|
afp/enums.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class OrderType(StrEnum):
|
|
5
|
+
LIMIT_ORDER = "LIMIT_ORDER"
|
|
6
|
+
CANCEL_ORDER = "CANCEL_ORDER"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OrderState(StrEnum):
|
|
10
|
+
RECEIVED = "RECEIVED"
|
|
11
|
+
PENDING = "PENDING"
|
|
12
|
+
OPEN = "OPEN"
|
|
13
|
+
PARTIAL = "PARTIAL"
|
|
14
|
+
COMPLETED = "COMPLETED"
|
|
15
|
+
CANCELLED = "CANCELLED"
|
|
16
|
+
REJECTED = "REJECTED"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OrderSide(StrEnum):
|
|
20
|
+
BID = "BID"
|
|
21
|
+
ASK = "ASK"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TradeState(StrEnum):
|
|
25
|
+
PENDING = "PENDING"
|
|
26
|
+
CLEARED = "CLEARED"
|
|
27
|
+
REJECTED = "REJECTED"
|
afp/exceptions.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
class BaseException(Exception):
|
|
2
|
+
pass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ClearingSystemError(BaseException):
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ExchangeError(BaseException):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AuthenticationError(ExchangeError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthorizationError(ExchangeError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NotFoundError(ExchangeError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ValidationError(ExchangeError):
|
|
26
|
+
pass
|
afp/exchange.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any, Generator
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
from requests import Response, Session
|
|
6
|
+
|
|
7
|
+
from . import config
|
|
8
|
+
from .exceptions import (
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
AuthorizationError,
|
|
11
|
+
ExchangeError,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
ValidationError,
|
|
14
|
+
)
|
|
15
|
+
from .schemas import (
|
|
16
|
+
ExchangeParameters,
|
|
17
|
+
ExchangeProduct,
|
|
18
|
+
ExchangeProductSubmission,
|
|
19
|
+
LoginSubmission,
|
|
20
|
+
MarketDepthData,
|
|
21
|
+
Order,
|
|
22
|
+
OrderFill,
|
|
23
|
+
OrderFillFilter,
|
|
24
|
+
OrderSubmission,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ExchangeClient:
|
|
29
|
+
_session: Session
|
|
30
|
+
|
|
31
|
+
def __init__(self):
|
|
32
|
+
self._session = Session()
|
|
33
|
+
|
|
34
|
+
# POST /nonce
|
|
35
|
+
def generate_login_nonce(self) -> str:
|
|
36
|
+
response = self._send_request("GET", "/nonce")
|
|
37
|
+
return response.json()["message"]
|
|
38
|
+
|
|
39
|
+
# POST /login
|
|
40
|
+
def login(self, login_submission: LoginSubmission) -> ExchangeParameters:
|
|
41
|
+
response = self._send_request(
|
|
42
|
+
"POST", "/login", data=login_submission.model_dump_json()
|
|
43
|
+
)
|
|
44
|
+
return ExchangeParameters(**response.json())
|
|
45
|
+
|
|
46
|
+
# GET /products
|
|
47
|
+
def get_approved_products(self) -> list[ExchangeProduct]:
|
|
48
|
+
response = self._send_request("GET", "/products")
|
|
49
|
+
return [ExchangeProduct(**item) for item in response.json()["products"]]
|
|
50
|
+
|
|
51
|
+
# GET /products/{product_id}
|
|
52
|
+
def get_product_by_id(self, product_id: str) -> ExchangeProduct:
|
|
53
|
+
response = self._send_request("GET", f"/products/{product_id}")
|
|
54
|
+
return ExchangeProduct(**response.json())
|
|
55
|
+
|
|
56
|
+
# POST /products
|
|
57
|
+
def approve_product(self, product_submission: ExchangeProductSubmission) -> None:
|
|
58
|
+
self._send_request(
|
|
59
|
+
"POST", "/products", data=product_submission.model_dump_json()
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# DELETE /products
|
|
63
|
+
def delist_product(self, product_id: str) -> None:
|
|
64
|
+
self._send_request("DELETE", f"/products/{product_id}")
|
|
65
|
+
|
|
66
|
+
# POST /orders
|
|
67
|
+
def submit_order(self, order_submission: OrderSubmission) -> Order:
|
|
68
|
+
response = self._send_request(
|
|
69
|
+
"POST", "/orders", data=order_submission.model_dump_json()
|
|
70
|
+
)
|
|
71
|
+
return Order(**response.json())
|
|
72
|
+
|
|
73
|
+
# GET /orders
|
|
74
|
+
def get_open_orders(self) -> list[Order]:
|
|
75
|
+
response = self._send_request("GET", "/orders")
|
|
76
|
+
return [Order(**item) for item in response.json()["orders"]]
|
|
77
|
+
|
|
78
|
+
# GET /orders/{order_id}
|
|
79
|
+
def get_order_by_id(self, order_id: str) -> Order:
|
|
80
|
+
response = self._send_request("GET", f"/orders/{order_id}")
|
|
81
|
+
return Order(**response.json())
|
|
82
|
+
|
|
83
|
+
# GET /order-fills
|
|
84
|
+
def get_order_fills(self, filter: OrderFillFilter) -> list[OrderFill]:
|
|
85
|
+
response = self._send_request(
|
|
86
|
+
"GET", "/order-fills", params=filter.model_dump(exclude_none=True)
|
|
87
|
+
)
|
|
88
|
+
return [OrderFill(**item) for item in response.json()["orderFills"]]
|
|
89
|
+
|
|
90
|
+
# GET /stream/order-fills
|
|
91
|
+
def iter_order_fills(
|
|
92
|
+
self, filter: OrderFillFilter
|
|
93
|
+
) -> Generator[OrderFill, None, None]:
|
|
94
|
+
response = self._send_request(
|
|
95
|
+
"GET",
|
|
96
|
+
"/stream/order-fills",
|
|
97
|
+
params=filter.model_dump(exclude_none=True),
|
|
98
|
+
stream=True,
|
|
99
|
+
)
|
|
100
|
+
for line in response.iter_lines():
|
|
101
|
+
yield OrderFill.model_validate_json(line)
|
|
102
|
+
|
|
103
|
+
# GET /market-depth/{product_id}
|
|
104
|
+
def get_market_depth_data(self, product_id: str) -> MarketDepthData:
|
|
105
|
+
response = self._send_request("GET", f"/market-depth/{product_id}")
|
|
106
|
+
return MarketDepthData(**response.json())
|
|
107
|
+
|
|
108
|
+
# GET /stream/market-depth/{product_id}
|
|
109
|
+
def iter_market_depth_data(
|
|
110
|
+
self, product_id: str
|
|
111
|
+
) -> Generator[MarketDepthData, None, None]:
|
|
112
|
+
response = self._send_request(
|
|
113
|
+
"GET", f"/stream/market-depth/{product_id}", stream=True
|
|
114
|
+
)
|
|
115
|
+
for line in response.iter_lines():
|
|
116
|
+
yield MarketDepthData.model_validate_json(line)
|
|
117
|
+
|
|
118
|
+
def _send_request(
|
|
119
|
+
self, method: str, endpoint: str, stream: bool = False, **kwargs: Any
|
|
120
|
+
) -> Response:
|
|
121
|
+
kwargs["headers"] = {
|
|
122
|
+
"Content-Type": "application/json",
|
|
123
|
+
"Accept": "application/x-ndjson" if stream else "application/json",
|
|
124
|
+
"User-Agent": config.USER_AGENT,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
response = self._session.request(
|
|
129
|
+
method, f"{config.EXCHANGE_URL}{endpoint}", stream=stream, **kwargs
|
|
130
|
+
)
|
|
131
|
+
except requests.exceptions.RequestException as request_exception:
|
|
132
|
+
raise ExchangeError(
|
|
133
|
+
"Failed to send request to the exchange"
|
|
134
|
+
) from request_exception
|
|
135
|
+
try:
|
|
136
|
+
response.raise_for_status()
|
|
137
|
+
except requests.exceptions.HTTPError as http_error:
|
|
138
|
+
if http_error.response.status_code == requests.codes.UNAUTHORIZED:
|
|
139
|
+
raise AuthenticationError(http_error) from http_error
|
|
140
|
+
if http_error.response.status_code == requests.codes.FORBIDDEN:
|
|
141
|
+
raise AuthorizationError(http_error) from http_error
|
|
142
|
+
if http_error.response.status_code == requests.codes.NOT_FOUND:
|
|
143
|
+
raise NotFoundError(http_error) from http_error
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
reason = response.json()["detail"]
|
|
147
|
+
except (json.JSONDecodeError, KeyError):
|
|
148
|
+
reason = response.text
|
|
149
|
+
raise ValidationError(reason) from http_error
|
|
150
|
+
|
|
151
|
+
return response
|
afp/py.typed
ADDED
|
File without changes
|
afp/schemas.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from decimal import Decimal
|
|
3
|
+
from functools import partial
|
|
4
|
+
from typing import Annotated, Any
|
|
5
|
+
|
|
6
|
+
import inflection
|
|
7
|
+
from pydantic import (
|
|
8
|
+
AfterValidator,
|
|
9
|
+
AliasGenerator,
|
|
10
|
+
BaseModel,
|
|
11
|
+
BeforeValidator,
|
|
12
|
+
ConfigDict,
|
|
13
|
+
Field,
|
|
14
|
+
PlainSerializer,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from . import validators
|
|
18
|
+
from .enums import OrderSide, OrderState, OrderType, TradeState
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Use datetime internally but UNIX timestamp in client-server communication
|
|
22
|
+
Timestamp = Annotated[
|
|
23
|
+
datetime,
|
|
24
|
+
BeforeValidator(validators.ensure_datetime),
|
|
25
|
+
PlainSerializer(validators.ensure_timestamp, return_type=int, when_used="json"),
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Model(BaseModel):
|
|
30
|
+
model_config = ConfigDict(
|
|
31
|
+
alias_generator=AliasGenerator(
|
|
32
|
+
alias=partial(inflection.camelize, uppercase_first_letter=False),
|
|
33
|
+
),
|
|
34
|
+
frozen=True,
|
|
35
|
+
populate_by_name=True,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Change the default value of by_alias to True
|
|
39
|
+
def model_dump_json(self, by_alias: bool = True, **kwargs: Any) -> str:
|
|
40
|
+
return super().model_dump_json(by_alias=by_alias, **kwargs)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Authentication
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class LoginSubmission(Model):
|
|
47
|
+
message: str
|
|
48
|
+
signature: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ExchangeParameters(Model):
|
|
52
|
+
trading_protocol_id: str
|
|
53
|
+
trading_fee_rate: Decimal
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Trading API
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ExchangeProductSubmission(Model):
|
|
60
|
+
id: Annotated[str, AfterValidator(validators.validate_hexstr32)]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ExchangeProduct(Model):
|
|
64
|
+
id: str
|
|
65
|
+
symbol: str
|
|
66
|
+
tick_size: int
|
|
67
|
+
collateral_asset: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class IntentData(Model):
|
|
71
|
+
trading_protocol_id: str
|
|
72
|
+
product_id: str
|
|
73
|
+
limit_price: Annotated[Decimal, Field(gt=0)]
|
|
74
|
+
quantity: Annotated[int, Field(gt=0)]
|
|
75
|
+
max_trading_fee_rate: Annotated[Decimal, Field(ge=0)]
|
|
76
|
+
side: OrderSide
|
|
77
|
+
good_until_time: Timestamp
|
|
78
|
+
nonce: int
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Intent(Model):
|
|
82
|
+
hash: str
|
|
83
|
+
margin_account_id: str
|
|
84
|
+
intent_account_id: str
|
|
85
|
+
signature: str
|
|
86
|
+
data: IntentData
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Order(Model):
|
|
90
|
+
id: str
|
|
91
|
+
type: OrderType
|
|
92
|
+
timestamp: Timestamp
|
|
93
|
+
state: OrderState
|
|
94
|
+
fill_quantity: int
|
|
95
|
+
intent: Intent
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class OrderCancellationData(Model):
|
|
99
|
+
intent_hash: Annotated[str, AfterValidator(validators.validate_hexstr32)]
|
|
100
|
+
nonce: int
|
|
101
|
+
intent_account_id: str
|
|
102
|
+
signature: str
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class OrderSubmission(Model):
|
|
106
|
+
type: OrderType
|
|
107
|
+
intent: Intent | None = None
|
|
108
|
+
cancellation_data: OrderCancellationData | None = None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Trade(Model):
|
|
112
|
+
id: int
|
|
113
|
+
product_id: str
|
|
114
|
+
price: Decimal
|
|
115
|
+
timestamp: Timestamp
|
|
116
|
+
state: TradeState
|
|
117
|
+
transaction_id: str | None
|
|
118
|
+
rejection_reason: str | None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class OrderFill(Model):
|
|
122
|
+
order: Order
|
|
123
|
+
trade: Trade
|
|
124
|
+
quantity: int
|
|
125
|
+
price: Decimal
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class OrderFillFilter(Model):
|
|
129
|
+
intent_account_id: str
|
|
130
|
+
product_id: None | Annotated[str, AfterValidator(validators.validate_hexstr32)]
|
|
131
|
+
margin_account_id: (
|
|
132
|
+
None | Annotated[str, AfterValidator(validators.validate_address)]
|
|
133
|
+
)
|
|
134
|
+
intent_hash: None | Annotated[str, AfterValidator(validators.validate_hexstr32)]
|
|
135
|
+
start: None | Timestamp
|
|
136
|
+
end: None | Timestamp
|
|
137
|
+
trade_state: None | TradeState
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class MarketDepthItem(Model):
|
|
141
|
+
price: Decimal
|
|
142
|
+
quantity: int
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class MarketDepthData(Model):
|
|
146
|
+
product_id: str
|
|
147
|
+
bids: list[MarketDepthItem]
|
|
148
|
+
asks: list[MarketDepthItem]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# Clearing API
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class Position(Model):
|
|
155
|
+
id: str
|
|
156
|
+
quantity: int
|
|
157
|
+
cost_basis: Decimal
|
|
158
|
+
maintenance_margin: Decimal
|
|
159
|
+
pnl: Decimal
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# Builder API
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class ProductSpecification(Model):
|
|
166
|
+
id: str
|
|
167
|
+
# Product Metadata
|
|
168
|
+
builder_id: str
|
|
169
|
+
symbol: str
|
|
170
|
+
description: str
|
|
171
|
+
# Orace Specification
|
|
172
|
+
oracle_address: Annotated[str, AfterValidator(validators.validate_address)]
|
|
173
|
+
fsv_decimals: Annotated[int, Field(ge=0, lt=256)] # uint8
|
|
174
|
+
fsp_alpha: Decimal
|
|
175
|
+
fsp_beta: int
|
|
176
|
+
fsv_calldata: Annotated[str, AfterValidator(validators.validate_hexstr)]
|
|
177
|
+
# Product
|
|
178
|
+
start_time: Timestamp
|
|
179
|
+
earliest_fsp_submission_time: Timestamp
|
|
180
|
+
collateral_asset: Annotated[str, AfterValidator(validators.validate_address)]
|
|
181
|
+
price_quotation: str
|
|
182
|
+
tick_size: Annotated[int, Field(ge=0)]
|
|
183
|
+
unit_value: Annotated[Decimal, Field(gt=0)]
|
|
184
|
+
initial_margin_requirement: Annotated[Decimal, Field(gt=0)]
|
|
185
|
+
maintenance_margin_requirement: Annotated[Decimal, Field(gt=0)]
|
|
186
|
+
offer_price_buffer: Annotated[Decimal, Field(ge=0, lt=1)]
|
|
187
|
+
auction_bounty: Annotated[Decimal, Field(ge=0, le=1)]
|
|
188
|
+
tradeout_interval: Annotated[int, Field(ge=0)]
|
|
189
|
+
extended_metadata: str
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# Liquidation API
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class Bid(Model):
|
|
196
|
+
product_id: Annotated[str, AfterValidator(validators.validate_hexstr32)]
|
|
197
|
+
price: Annotated[Decimal, Field(gt=0)]
|
|
198
|
+
quantity: Annotated[int, Field(gt=0)]
|
|
199
|
+
side: OrderSide
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class AuctionData(Model):
|
|
203
|
+
start_block: int
|
|
204
|
+
margin_account_equity_at_initiation: Decimal
|
|
205
|
+
maintenance_margin_used_at_initiation: Decimal
|
|
206
|
+
margin_account_equity_now: Decimal
|
|
207
|
+
maintenance_margin_used_now: Decimal
|
afp/signing.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from typing import Any, cast
|
|
2
|
+
|
|
3
|
+
from eth_abi.packed import encode_packed
|
|
4
|
+
from eth_account import messages
|
|
5
|
+
from eth_typing.evm import ChecksumAddress
|
|
6
|
+
from eth_account.signers.local import LocalAccount
|
|
7
|
+
from hexbytes import HexBytes
|
|
8
|
+
from web3 import Web3
|
|
9
|
+
|
|
10
|
+
from . import config
|
|
11
|
+
from .bindings import clearing_facet
|
|
12
|
+
from .schemas import IntentData, OrderSide
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
ORDER_SIDE_MAPPING: dict[OrderSide, int] = {
|
|
16
|
+
OrderSide.BID: clearing_facet.Side.BID.value,
|
|
17
|
+
OrderSide.ASK: clearing_facet.Side.ASK.value,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def generate_intent_hash(
|
|
22
|
+
intent_data: IntentData,
|
|
23
|
+
margin_account_id: ChecksumAddress,
|
|
24
|
+
intent_account_id: ChecksumAddress,
|
|
25
|
+
tick_size: int,
|
|
26
|
+
) -> HexBytes:
|
|
27
|
+
types = [
|
|
28
|
+
"address", # margin_account_id
|
|
29
|
+
"address", # intent_account_id
|
|
30
|
+
"uint256", # nonce
|
|
31
|
+
"address", # trading_protocol_id
|
|
32
|
+
"bytes32", # product_id
|
|
33
|
+
"uint256", # limit_price
|
|
34
|
+
"uint256", # quantity
|
|
35
|
+
"uint256", # max_trading_fee_rate
|
|
36
|
+
"uint256", # good_until_time
|
|
37
|
+
"uint8", # side
|
|
38
|
+
]
|
|
39
|
+
values: list[Any] = [
|
|
40
|
+
margin_account_id,
|
|
41
|
+
intent_account_id,
|
|
42
|
+
intent_data.nonce,
|
|
43
|
+
cast(ChecksumAddress, intent_data.trading_protocol_id),
|
|
44
|
+
HexBytes(intent_data.product_id),
|
|
45
|
+
int(intent_data.limit_price * 10**tick_size),
|
|
46
|
+
intent_data.quantity,
|
|
47
|
+
int(intent_data.max_trading_fee_rate * config.FEE_RATE_MULTIPLIER),
|
|
48
|
+
int(intent_data.good_until_time.timestamp()),
|
|
49
|
+
ORDER_SIDE_MAPPING[intent_data.side],
|
|
50
|
+
]
|
|
51
|
+
return Web3.keccak(encode_packed(types, values))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def generate_order_cancellation_hash(nonce: int, intent_hash: str) -> HexBytes:
|
|
55
|
+
types = ["uint256", "bytes32"]
|
|
56
|
+
values: list[Any] = [nonce, HexBytes(intent_hash)]
|
|
57
|
+
return Web3.keccak(encode_packed(types, values))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def generate_product_id(builder: LocalAccount, symbol: str) -> HexBytes:
|
|
61
|
+
types = ["address", "string"]
|
|
62
|
+
values: list[Any] = [builder.address, symbol]
|
|
63
|
+
return Web3.keccak(encode_packed(types, values))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def sign_message(account: LocalAccount, message: bytes) -> HexBytes:
|
|
67
|
+
eip191_message = messages.encode_defunct(message)
|
|
68
|
+
signed_message = account.sign_message(eip191_message)
|
|
69
|
+
return signed_message.signature
|
afp/validators.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from datetime import datetime, timedelta
|
|
2
|
+
|
|
3
|
+
from binascii import Error
|
|
4
|
+
from eth_typing.evm import ChecksumAddress
|
|
5
|
+
from hexbytes import HexBytes
|
|
6
|
+
from web3 import Web3
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def ensure_timestamp(value: int | datetime) -> int:
|
|
10
|
+
return int(value.timestamp()) if isinstance(value, datetime) else value
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def ensure_datetime(value: datetime | int) -> datetime:
|
|
14
|
+
return value if isinstance(value, datetime) else datetime.fromtimestamp(value)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def validate_timedelta(value: timedelta) -> timedelta:
|
|
18
|
+
if value.total_seconds() < 0:
|
|
19
|
+
raise ValueError(f"{value} should be positive")
|
|
20
|
+
return value
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def validate_hexstr(value: str, length: int | None = None) -> str:
|
|
24
|
+
if not value.startswith("0x"):
|
|
25
|
+
raise ValueError(f"{value} should start with '0x'")
|
|
26
|
+
try:
|
|
27
|
+
byte_value = HexBytes(value)
|
|
28
|
+
except Error:
|
|
29
|
+
raise ValueError(f"{value} includes non-hexadecimal characters")
|
|
30
|
+
if length is not None and len(byte_value) != length:
|
|
31
|
+
raise ValueError(f"{value} should be 32 bytes long")
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def validate_hexstr32(value: str) -> str:
|
|
36
|
+
return validate_hexstr(value, length=32)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def validate_address(value: str) -> ChecksumAddress:
|
|
40
|
+
try:
|
|
41
|
+
return Web3.to_checksum_address(value)
|
|
42
|
+
except ValueError:
|
|
43
|
+
raise ValueError(f"{value} is not a valid blockchain address")
|