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/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")