ddx-python 1.0.5__cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.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.
Files changed (104) hide show
  1. ddx/.gitignore +1 -0
  2. ddx/__init__.py +58 -0
  3. ddx/_rust/__init__.pyi +2009 -0
  4. ddx/_rust/common/__init__.pyi +17 -0
  5. ddx/_rust/common/accounting.pyi +6 -0
  6. ddx/_rust/common/enums.pyi +3 -0
  7. ddx/_rust/common/requests/__init__.pyi +21 -0
  8. ddx/_rust/common/requests/intents.pyi +19 -0
  9. ddx/_rust/common/specs.pyi +17 -0
  10. ddx/_rust/common/state/__init__.pyi +41 -0
  11. ddx/_rust/common/state/keys.pyi +29 -0
  12. ddx/_rust/common/transactions.pyi +7 -0
  13. ddx/_rust/decimal.pyi +3 -0
  14. ddx/_rust/h256.pyi +3 -0
  15. ddx/_rust.abi3.so +0 -0
  16. ddx/app_config/ethereum/addresses.json +541 -0
  17. ddx/auditor/README.md +32 -0
  18. ddx/auditor/__init__.py +0 -0
  19. ddx/auditor/auditor_driver.py +1034 -0
  20. ddx/auditor/websocket_message.py +54 -0
  21. ddx/common/__init__.py +0 -0
  22. ddx/common/epoch_params.py +28 -0
  23. ddx/common/fill_context.py +144 -0
  24. ddx/common/item_utils.py +38 -0
  25. ddx/common/logging.py +184 -0
  26. ddx/common/market_specs.py +64 -0
  27. ddx/common/trade_mining_params.py +19 -0
  28. ddx/common/transaction_utils.py +85 -0
  29. ddx/common/transactions/__init__.py +0 -0
  30. ddx/common/transactions/advance_epoch.py +91 -0
  31. ddx/common/transactions/advance_settlement_epoch.py +63 -0
  32. ddx/common/transactions/all_price_checkpoints.py +84 -0
  33. ddx/common/transactions/cancel.py +76 -0
  34. ddx/common/transactions/cancel_all.py +88 -0
  35. ddx/common/transactions/complete_fill.py +103 -0
  36. ddx/common/transactions/disaster_recovery.py +97 -0
  37. ddx/common/transactions/event.py +48 -0
  38. ddx/common/transactions/fee_distribution.py +119 -0
  39. ddx/common/transactions/funding.py +294 -0
  40. ddx/common/transactions/futures_expiry.py +123 -0
  41. ddx/common/transactions/genesis.py +108 -0
  42. ddx/common/transactions/inner/__init__.py +0 -0
  43. ddx/common/transactions/inner/adl_outcome.py +25 -0
  44. ddx/common/transactions/inner/fill.py +227 -0
  45. ddx/common/transactions/inner/liquidated_position.py +41 -0
  46. ddx/common/transactions/inner/liquidation_entry.py +41 -0
  47. ddx/common/transactions/inner/liquidation_fill.py +118 -0
  48. ddx/common/transactions/inner/outcome.py +32 -0
  49. ddx/common/transactions/inner/trade_fill.py +125 -0
  50. ddx/common/transactions/insurance_fund_update.py +142 -0
  51. ddx/common/transactions/insurance_fund_withdraw.py +99 -0
  52. ddx/common/transactions/liquidation.py +357 -0
  53. ddx/common/transactions/partial_fill.py +125 -0
  54. ddx/common/transactions/pnl_realization.py +122 -0
  55. ddx/common/transactions/post.py +72 -0
  56. ddx/common/transactions/post_order.py +95 -0
  57. ddx/common/transactions/price_checkpoint.py +96 -0
  58. ddx/common/transactions/signer_registered.py +62 -0
  59. ddx/common/transactions/specs_update.py +61 -0
  60. ddx/common/transactions/strategy_update.py +156 -0
  61. ddx/common/transactions/tradable_product_update.py +98 -0
  62. ddx/common/transactions/trade_mining.py +147 -0
  63. ddx/common/transactions/trader_update.py +105 -0
  64. ddx/common/transactions/withdraw.py +91 -0
  65. ddx/common/transactions/withdraw_ddx.py +74 -0
  66. ddx/common/utils.py +176 -0
  67. ddx/config.py +17 -0
  68. ddx/derivadex_client.py +254 -0
  69. ddx/py.typed +0 -0
  70. ddx/realtime_client/__init__.py +2 -0
  71. ddx/realtime_client/config.py +2 -0
  72. ddx/realtime_client/logs/pytest.log +0 -0
  73. ddx/realtime_client/models/__init__.py +683 -0
  74. ddx/realtime_client/realtime_client.py +567 -0
  75. ddx/rest_client/__init__.py +0 -0
  76. ddx/rest_client/clients/__init__.py +0 -0
  77. ddx/rest_client/clients/base_client.py +60 -0
  78. ddx/rest_client/clients/market_client.py +1241 -0
  79. ddx/rest_client/clients/on_chain_client.py +432 -0
  80. ddx/rest_client/clients/signed_client.py +301 -0
  81. ddx/rest_client/clients/system_client.py +843 -0
  82. ddx/rest_client/clients/trade_client.py +335 -0
  83. ddx/rest_client/constants/__init__.py +0 -0
  84. ddx/rest_client/constants/endpoints.py +67 -0
  85. ddx/rest_client/contracts/__init__.py +0 -0
  86. ddx/rest_client/contracts/checkpoint/__init__.py +560 -0
  87. ddx/rest_client/contracts/ddx/__init__.py +1949 -0
  88. ddx/rest_client/contracts/dummy_token/__init__.py +1014 -0
  89. ddx/rest_client/contracts/i_collateral/__init__.py +1414 -0
  90. ddx/rest_client/contracts/i_stake/__init__.py +696 -0
  91. ddx/rest_client/exceptions/__init__.py +0 -0
  92. ddx/rest_client/exceptions/exceptions.py +32 -0
  93. ddx/rest_client/http/__init__.py +0 -0
  94. ddx/rest_client/http/http_client.py +305 -0
  95. ddx/rest_client/models/__init__.py +0 -0
  96. ddx/rest_client/models/market.py +683 -0
  97. ddx/rest_client/models/signed.py +60 -0
  98. ddx/rest_client/models/system.py +390 -0
  99. ddx/rest_client/models/trade.py +140 -0
  100. ddx/rest_client/utils/__init__.py +0 -0
  101. ddx/rest_client/utils/encryption_utils.py +26 -0
  102. ddx_python-1.0.5.dist-info/METADATA +63 -0
  103. ddx_python-1.0.5.dist-info/RECORD +104 -0
  104. ddx_python-1.0.5.dist-info/WHEEL +4 -0
@@ -0,0 +1,1034 @@
1
+ """
2
+ AuditorDriver module
3
+ """
4
+
5
+ import asyncio
6
+ import datetime
7
+ from collections import defaultdict
8
+ from collections.abc import AsyncIterable
9
+ from typing import Optional, Type, TypeVar
10
+ import requests
11
+ import simplejson as json
12
+ import websockets
13
+ from websockets import WebSocketClientProtocol
14
+ from web3.auto import w3
15
+
16
+ from ddx._rust.common import ProductSymbol
17
+ from ddx._rust.common.state import DerivadexSMT, Item, ItemKind, Price
18
+ from ddx._rust.common.state.keys import (
19
+ BookOrderKey,
20
+ InsuranceFundKey,
21
+ PositionKey,
22
+ PriceKey,
23
+ StrategyKey,
24
+ TraderKey,
25
+ )
26
+ from ddx._rust.decimal import Decimal
27
+ from ddx._rust.h256 import H256
28
+
29
+ from ddx.common.epoch_params import EpochParams
30
+ from ddx.common.logging import CHECKMARK, auditor_logger
31
+ from ddx.common.trade_mining_params import TradeMiningParams
32
+ from ddx.common.transactions.advance_epoch import AdvanceEpoch
33
+ from ddx.common.transactions.advance_settlement_epoch import AdvanceSettlementEpoch
34
+ from ddx.common.transactions.all_price_checkpoints import AllPriceCheckpoints
35
+ from ddx.common.transactions.cancel import Cancel
36
+ from ddx.common.transactions.cancel_all import CancelAll
37
+ from ddx.common.transactions.complete_fill import CompleteFill
38
+ from ddx.common.transactions.disaster_recovery import DisasterRecovery
39
+ from ddx.common.transactions.event import Event
40
+ from ddx.common.transactions.fee_distribution import FeeDistribution
41
+ from ddx.common.transactions.funding import Funding
42
+ from ddx.common.transactions.futures_expiry import FuturesExpiry
43
+ from ddx.common.transactions.genesis import Genesis
44
+ from ddx.common.transactions.insurance_fund_update import InsuranceFundUpdate
45
+ from ddx.common.transactions.insurance_fund_withdraw import InsuranceFundWithdraw
46
+ from ddx.common.transactions.liquidation import Liquidation
47
+ from ddx.common.transactions.partial_fill import PartialFill
48
+ from ddx.common.transactions.pnl_realization import PnlRealization
49
+ from ddx.common.transactions.post_order import PostOrder
50
+ from ddx.common.transactions.signer_registered import SignerRegistered
51
+ from ddx.common.transactions.specs_update import SpecsUpdate
52
+ from ddx.common.transactions.strategy_update import StrategyUpdate
53
+ from ddx.common.transactions.tradable_product_update import TradableProductUpdate
54
+ from ddx.common.transactions.trade_mining import TradeMining
55
+ from ddx.common.transactions.trader_update import TraderUpdate
56
+ from ddx.common.transactions.withdraw import Withdraw
57
+ from ddx.common.transactions.withdraw_ddx import WithdrawDDX
58
+ from ddx.common.utils import get_parsed_tx_log_entry, ComplexOutputEncoder
59
+ from ddx.auditor.websocket_message import WebsocketEventType, WebsocketMessage
60
+
61
+
62
+ logger = auditor_logger(__name__)
63
+
64
+ EventT = TypeVar("EventT", bound=Event)
65
+ RAW_TYPE_TO_EVENT_TYPE: dict[str, Type[EventT]] = {
66
+ "Post": PostOrder,
67
+ "CompleteFill": CompleteFill,
68
+ "PartialFill": PartialFill,
69
+ "Liquidation": Liquidation,
70
+ "Cancel": Cancel,
71
+ "CancelAll": CancelAll,
72
+ "StrategyUpdate": StrategyUpdate,
73
+ "TraderUpdate": TraderUpdate,
74
+ "PriceCheckpoint": AllPriceCheckpoints,
75
+ "PnlRealization": PnlRealization,
76
+ "Funding": Funding,
77
+ "FuturesExpiry": FuturesExpiry,
78
+ "TradeMining": TradeMining,
79
+ "Withdraw": Withdraw,
80
+ "WithdrawDDX": WithdrawDDX,
81
+ "InsuranceFundWithdraw": InsuranceFundWithdraw,
82
+ "Genesis": Genesis,
83
+ "AdvanceEpoch": AdvanceEpoch,
84
+ "AdvanceSettlementEpoch": AdvanceSettlementEpoch,
85
+ "InsuranceFundUpdate": InsuranceFundUpdate,
86
+ "DisasterRecovery": DisasterRecovery,
87
+ "FeeDistribution": FeeDistribution,
88
+ "SignerRegistered": SignerRegistered,
89
+ "SpecsUpdate": SpecsUpdate,
90
+ "TradableProductUpdate": TradableProductUpdate,
91
+ }
92
+
93
+
94
+ def empty_queue(q: asyncio.Queue):
95
+ for _ in range(q.qsize()):
96
+ # Depending on your program, you may want to
97
+ # catch QueueEmpty
98
+ q.get_nowait()
99
+ q.task_done()
100
+
101
+
102
+ class AuditorDriver:
103
+ """
104
+ Defines an AuditorDriver.
105
+ """
106
+
107
+ def __init__(
108
+ self,
109
+ webserver_url: str,
110
+ genesis_params: dict,
111
+ epoch_params: EpochParams,
112
+ trade_mining_params: TradeMiningParams,
113
+ collateral_tranches: list[tuple[Decimal, Decimal]],
114
+ contract_deployment: str,
115
+ ):
116
+ """
117
+ Initialize an AuditorDriver. An Auditor allows any third-party to
118
+ process a state snapshot of the DerivaDEX Sparse Merkle Tree (SMT)
119
+ and transaction log entries to validate the integrity of the
120
+ exchange. The driver essentially maintains its own SMT and can
121
+ transition its state upon receiving transaction log entries. The
122
+ root hashes must match.
123
+
124
+ Parameters
125
+ ----------
126
+ webserver_url: str
127
+ Operator hostname
128
+ epoch_params: EpochParams
129
+ Epoch parameters
130
+ collateral_tranches: list[tuple[Decimal, Decimal]]
131
+ Collateral guards tranches
132
+ genesis_params : dict
133
+ Genesis params for the environment
134
+ contract_deployment: str
135
+ Contract deployment name, e.g. geth
136
+ """
137
+
138
+ self.webserver_url = webserver_url
139
+ self.contract_deployment = contract_deployment
140
+ self.epoch_params = epoch_params
141
+ self.trade_mining_params = trade_mining_params
142
+ self.collateral_tranches = collateral_tranches
143
+ self.genesis_params = genesis_params
144
+
145
+ def _reset(self):
146
+ # Initialize an empty SMT
147
+ self.smt = DerivadexSMT()
148
+
149
+ # Initialize latest price leaves. These
150
+ # technically are abstractions above the SMT for easier/faster
151
+ # access for a trader client
152
+ self.latest_price_leaves: dict[ProductSymbol,
153
+ tuple[PriceKey, Price]] = {}
154
+
155
+ # Initialize a data construct for pending transaction log
156
+ # entries. We maintain a backlog of pending transaction log entries
157
+ # in this scenario so that although we may receive transaction log
158
+ # entries out of order, we will always apply them to the SMT in order.
159
+ self.pending_tx_log_entries = defaultdict(dict)
160
+
161
+ # Current root hash derived locally. For every transaction event
162
+ # emitted by the transaction log, the state root hash prior to
163
+ # the transaction being applied. As such, we maintain the
164
+ # current root hash to compare against the next inbound
165
+ # transaction log's root hash.
166
+ self.current_state_root_hash = (
167
+ "0x0000000000000000000000000000000000000000000000000000000000000000"
168
+ )
169
+ self.current_batch_state_root_hash = (
170
+ "0x0000000000000000000000000000000000000000000000000000000000000000"
171
+ )
172
+
173
+ self.first_head = True
174
+ self._snapshot_received = False
175
+
176
+ # More Pythonic ask-for-forgiveness approaches for the queues
177
+ # and event below
178
+
179
+ # set up an asyncio queue for messages that will be added by
180
+ # the Auditor and popped to send to the API
181
+ try:
182
+ empty_queue(self.api_auditor_queue)
183
+ except AttributeError:
184
+ self.api_auditor_queue = asyncio.Queue()
185
+
186
+ self.expected_epoch_id = 0
187
+ self.expected_tx_ordinal = 0
188
+ self.latest_batch_id = 0
189
+
190
+ self.is_trade_mining = (
191
+ lambda epoch_id: epoch_id * self.epoch_params.epoch_size
192
+ < self.trade_mining_params.trade_mining_length
193
+ * self.epoch_params.trade_mining_period
194
+ + 1
195
+ )
196
+
197
+ @property
198
+ def smt(self):
199
+ return self._smt
200
+
201
+ @smt.setter
202
+ def smt(self, smt):
203
+ self._smt = smt
204
+
205
+ @property
206
+ def expected_epoch_id(self):
207
+ return self._expected_epoch_id
208
+
209
+ @expected_epoch_id.setter
210
+ def expected_epoch_id(self, epoch_id):
211
+ self._expected_epoch_id = epoch_id
212
+
213
+ @property
214
+ def expected_tx_ordinal(self):
215
+ return self._expected_tx_ordinal
216
+
217
+ @expected_tx_ordinal.setter
218
+ def expected_tx_ordinal(self, tx_ordinal):
219
+ self._expected_tx_ordinal = tx_ordinal
220
+
221
+ def process_tx(self, tx: Event, tx_log_event: dict):
222
+ """
223
+ Process an individual transaction. This transaction will be
224
+ appropriately decoded into the correct Transaction type and
225
+ handled different to adjust the SMT.
226
+
227
+ Parameters
228
+ ----------
229
+ tx : EventT
230
+ A transaction
231
+ """
232
+
233
+ if isinstance(tx, PostOrder):
234
+ # PostOrder transaction
235
+ tx.process_tx(
236
+ self.smt,
237
+ latest_price_leaves=self.latest_price_leaves,
238
+ )
239
+ elif isinstance(tx, CompleteFill):
240
+ # CompleteFill transaction
241
+ tx.process_tx(
242
+ self.smt,
243
+ latest_price_leaves=self.latest_price_leaves,
244
+ trade_mining_active=self.is_trade_mining(
245
+ tx_log_event["epochId"]),
246
+ epoch_id=tx_log_event["epochId"],
247
+ )
248
+ elif isinstance(tx, PartialFill):
249
+ # PartialFill transaction
250
+ tx.process_tx(
251
+ self.smt,
252
+ latest_price_leaves=self.latest_price_leaves,
253
+ trade_mining_active=self.is_trade_mining(
254
+ tx_log_event["epochId"]),
255
+ epoch_id=tx_log_event["epochId"],
256
+ )
257
+ elif isinstance(tx, Liquidation):
258
+ # Liquidation transaction
259
+ tx.process_tx(
260
+ self.smt,
261
+ latest_price_leaves=self.latest_price_leaves,
262
+ trade_mining_active=self.is_trade_mining(
263
+ tx_log_event["epochId"]),
264
+ epoch_id=tx_log_event["epochId"],
265
+ )
266
+ elif isinstance(tx, Cancel):
267
+ # Cancel transaction
268
+ tx.process_tx(
269
+ self.smt,
270
+ )
271
+ elif isinstance(tx, CancelAll):
272
+ # CancelAll transaction
273
+ tx.process_tx(
274
+ self.smt,
275
+ )
276
+ elif isinstance(tx, StrategyUpdate):
277
+ # StrategyUpdate transaction
278
+ tx.process_tx(
279
+ self.smt,
280
+ collateral_tranches=self.collateral_tranches,
281
+ )
282
+ elif isinstance(tx, TraderUpdate):
283
+ # TraderUpdate transaction
284
+ tx.process_tx(
285
+ self.smt,
286
+ )
287
+ elif isinstance(tx, AllPriceCheckpoints):
288
+ # AllPriceCheckpoints transaction
289
+ tx.process_tx(
290
+ self.smt,
291
+ latest_price_leaves=self.latest_price_leaves,
292
+ )
293
+ elif isinstance(tx, PnlRealization):
294
+ # PnlRealization transaction
295
+ tx.process_tx(
296
+ self.smt,
297
+ latest_price_leaves=self.latest_price_leaves,
298
+ )
299
+ elif isinstance(tx, Funding):
300
+ # Funding transaction
301
+ tx.process_tx(
302
+ self.smt,
303
+ latest_price_leaves=self.latest_price_leaves,
304
+ funding_period=self.epoch_params.funding_period,
305
+ )
306
+ elif isinstance(tx, FuturesExpiry):
307
+ # FuturesExpiry transaction
308
+ tx.process_tx(
309
+ self.smt,
310
+ latest_price_leaves=self.latest_price_leaves,
311
+ )
312
+ elif isinstance(tx, TradeMining):
313
+ # TradeMining transaction
314
+ tx.process_tx(
315
+ self.smt,
316
+ trade_mining_active=self.is_trade_mining(
317
+ tx_log_event["epochId"]),
318
+ trade_mining_reward_per_epoch=self.trade_mining_params.trade_mining_reward_per_epoch,
319
+ trade_mining_maker_reward_percentage=self.trade_mining_params.trade_mining_maker_reward_percentage,
320
+ trade_mining_taker_reward_percentage=self.trade_mining_params.trade_mining_taker_reward_percentage,
321
+ )
322
+ elif isinstance(tx, Withdraw):
323
+ # Withdraw transaction
324
+ tx.process_tx(
325
+ self.smt,
326
+ )
327
+ elif isinstance(tx, WithdrawDDX):
328
+ # WithdrawDDX transaction
329
+ tx.process_tx(
330
+ self.smt,
331
+ )
332
+ elif isinstance(tx, InsuranceFundWithdraw):
333
+ # InsuranceFundWithdraw transaction
334
+ tx.process_tx(
335
+ self.smt,
336
+ )
337
+ elif isinstance(tx, Genesis):
338
+ # Genesis transaction
339
+ tx.process_tx(
340
+ auditor_instance=self,
341
+ expected_epoch_id=AuditorDriver.expected_epoch_id.fset,
342
+ expected_tx_ordinal=AuditorDriver.expected_tx_ordinal.fset,
343
+ smt=AuditorDriver.smt.fset,
344
+ genesis_params=self.genesis_params,
345
+ current_time=datetime.datetime.fromtimestamp(
346
+ tx_log_event["timestamp"] / 1000, tz=datetime.timezone.utc
347
+ ),
348
+ )
349
+ elif isinstance(tx, AdvanceEpoch):
350
+ # AdvanceEpoch transaction
351
+ tx.process_tx(
352
+ self.smt,
353
+ auditor_instance=self,
354
+ expected_epoch_id=AuditorDriver.expected_epoch_id.fset,
355
+ expected_tx_ordinal=AuditorDriver.expected_tx_ordinal.fset,
356
+ )
357
+ elif isinstance(tx, AdvanceSettlementEpoch):
358
+ # AdvanceSettlementEpoch transaction
359
+ tx.process_tx(
360
+ self.smt,
361
+ latest_price_leaves=self.latest_price_leaves,
362
+ )
363
+ elif isinstance(tx, InsuranceFundUpdate):
364
+ # InsuranceFundUpdate transaction
365
+ tx.process_tx(
366
+ self.smt,
367
+ )
368
+ elif isinstance(tx, DisasterRecovery):
369
+ # DisasterRecovery transaction
370
+ tx.process_tx(
371
+ self.smt,
372
+ latest_price_leaves=self.latest_price_leaves,
373
+ )
374
+ elif isinstance(tx, FeeDistribution):
375
+ # FeeDistribution transaction
376
+ tx.process_tx(
377
+ self.smt,
378
+ )
379
+ elif isinstance(tx, SignerRegistered):
380
+ # SignerRegistered transaction
381
+ tx.process_tx(
382
+ self.smt,
383
+ )
384
+ elif isinstance(tx, SpecsUpdate):
385
+ # SpecsUpdate transaction
386
+ tx.process_tx(
387
+ self.smt,
388
+ )
389
+ elif isinstance(tx, TradableProductUpdate):
390
+ # TradableProductUpdate transaction
391
+ tx.process_tx(
392
+ self.smt,
393
+ )
394
+ else:
395
+ raise RuntimeError("Unhandled SMT transaction type: " + type(tx))
396
+
397
+ def process_tx_log_event(
398
+ self, tx_log_event: dict, suppress_trader_queue: bool
399
+ ) -> list:
400
+ """
401
+ Process an individual transaction log entry. Each entry will be
402
+ appropriately decoded into the correct Transaction type and
403
+ handled differently to adjust the SMT.
404
+
405
+ Parameters
406
+ ----------
407
+ tx_log_event : dict
408
+ A transaction log event
409
+ suppress_trader_queue : bool
410
+ Suppress trader queue messages
411
+ """
412
+
413
+ processed_txs = []
414
+ # Add the transaction log event to the pending transaction log
415
+ # entries to be processed either now or later
416
+ self.pending_tx_log_entries[tx_log_event["epochId"]][
417
+ tx_log_event["txOrdinal"]
418
+ ] = tx_log_event
419
+
420
+ # Loop through all the pending transaction log entries that
421
+ # should be processed now given the expected epoch ID and
422
+ # transaction ordinal
423
+ while (
424
+ self.expected_epoch_id in self.pending_tx_log_entries
425
+ and self.expected_tx_ordinal
426
+ in self.pending_tx_log_entries[self.expected_epoch_id]
427
+ ):
428
+ # Retrieve the transaction log entry event that should be
429
+ # processed now
430
+ tx_log_event = self.pending_tx_log_entries[self.expected_epoch_id].pop(
431
+ self.expected_tx_ordinal
432
+ )
433
+
434
+ if (
435
+ tx_log_event["batchId"] == self.latest_batch_id
436
+ and tx_log_event["stateRootHash"] != self.current_batch_state_root_hash
437
+ ):
438
+ raise RuntimeError(
439
+ f"Tx log root hash ({tx_log_event['stateRootHash']}) != current batch root hash ({self.current_batch_state_root_hash}"
440
+ )
441
+ elif (
442
+ tx_log_event["batchId"] != self.latest_batch_id
443
+ and tx_log_event["stateRootHash"] != self.current_state_root_hash
444
+ ):
445
+ # Ensure that the current local state root hash matches
446
+ # the inbound transaction log entry's state root hash
447
+ logger.info(
448
+ "state root mismatch detected - shutting down operators")
449
+ if self.contract_deployment == "geth":
450
+ self.shutdown_operators()
451
+
452
+ logger.error(
453
+ f"smt leaves before request {tx_log_event['requestIndex']} (result of request {tx_log_event['requestIndex'] - 1}, dump THIS request from the operator): {[(str(key), value.abi_encoded_value().hex()) for key, value in self.smt.all_leaves()]}\n\nHuman readable:\n{str(self.smt.all_leaves())}"
454
+ )
455
+
456
+ raise RuntimeError(
457
+ f"Tx log root hash ({tx_log_event['stateRootHash']}) != current root hash ({self.current_state_root_hash}"
458
+ )
459
+
460
+ # Extract the transaction type from the event (e.g. Post,
461
+ # CompleteFill, etc.)
462
+ tx_type = tx_log_event["event"]["t"]
463
+ if tx_type == "EpochMarker":
464
+ tx_type = tx_log_event["event"]["c"]["kind"]
465
+
466
+ logger.success(
467
+ f"{CHECKMARK} - processing ({tx_type}; tx log root hash ({tx_log_event['stateRootHash']}) == current root hash ({self.current_state_root_hash}; tx ({tx_log_event})"
468
+ )
469
+
470
+ tx_type = RAW_TYPE_TO_EVENT_TYPE[tx_type]
471
+ tx = tx_type.decode_value_into_cls(tx_log_event)
472
+ self.process_tx(tx, tx_log_event)
473
+
474
+ # set the current state root hash locally
475
+ self.current_state_root_hash = f"0x{self.smt.root().as_bytes().hex()}"
476
+
477
+ if self.latest_batch_id != tx_log_event["batchId"]:
478
+ self.current_batch_state_root_hash = tx_log_event["stateRootHash"]
479
+ self.latest_batch_id = tx_log_event["batchId"]
480
+
481
+ # Increment the expected transaction ordinal by 1 (will be
482
+ # reset back to 0 only when the epoch advances)
483
+ self.expected_tx_ordinal += 1
484
+ logger.success(
485
+ f"{CHECKMARK * 2} - processed {tx_type}; arrived at new state root hash ({self.current_state_root_hash})"
486
+ )
487
+
488
+ processed_txs.append(tx)
489
+
490
+ return processed_txs
491
+
492
+ def process_state_snapshot(
493
+ self, expected_epoch_id: int, state_snapshot: dict
494
+ ) -> None:
495
+ """
496
+ Process a state snapshot and initialize the SMT accordingly.
497
+
498
+ Parameters
499
+ ----------
500
+ expected_epoch_id : int
501
+ Expected epoch ID for incoming transactions after the
502
+ state snapshot
503
+ state_snapshot : dict
504
+ The state snapshot structured as a dictionary with the
505
+ format: {<hash(leaf_key, leaf_value)>, (leaf_key, leaf_value)}
506
+ """
507
+
508
+ # Loop through state snapshot dictionary items
509
+ for state_snapshot_key, state_snapshot_value in state_snapshot.items():
510
+ # Compute the first and second words since with these two
511
+ # blocks of data, we can determine what type of leaf we are
512
+ # dealing with
513
+
514
+ state_snapshot_key = bytes.fromhex(state_snapshot_key[2:])
515
+ # Peel the item discriminant off (the first byte of the
516
+ # leaf key) to determine what kind of leaf it is
517
+ item_discriminant = ItemKind(w3.to_int(state_snapshot_key[:1]))
518
+
519
+ item = Item.abi_decode_value_into_item(
520
+ item_discriminant, bytes.fromhex(state_snapshot_value[2:])
521
+ )
522
+
523
+ state_snapshot_key_h256 = H256.from_bytes(state_snapshot_key)
524
+ self.smt.store_item_by_key(state_snapshot_key_h256, item)
525
+
526
+ if item_discriminant == ItemKind.Price:
527
+ # Update latest price leaves abstraction with the new
528
+ # price checkpoint data
529
+ price_key = PriceKey.decode_key(state_snapshot_key_h256)
530
+ price_item = Price.from_item(item)
531
+ # Derive the Price encoded key and H256
532
+ # repr
533
+ if (
534
+ price_key.symbol not in self.latest_price_leaves
535
+ or price_item.ordinal
536
+ > self.latest_price_leaves[price_key.symbol][1].ordinal
537
+ ):
538
+ self.latest_price_leaves[price_key.symbol] = (
539
+ price_key,
540
+ price_item,
541
+ )
542
+
543
+ self.expected_epoch_id = expected_epoch_id
544
+ self.expected_tx_ordinal = 0
545
+
546
+ # ************** DATA GETTERS ************** #
547
+
548
+ def get_trader_snapshot(self, trader_address: Optional[str]) -> list[dict]:
549
+ """
550
+ Get a snapshot of Trader leaves given a particular key.
551
+
552
+ Parameters
553
+ ----------
554
+ trader_address : str
555
+ Trader address
556
+ """
557
+
558
+ def topic_string(trader_key: TraderKey):
559
+ return f"{'/'.join(filter(None, ['STATE', 'TRADER', trader_key]))}/"
560
+
561
+ def encompasses_key(against_key: TraderKey):
562
+ if trader_address is not None:
563
+ return against_key.trader_address == trader_address
564
+ return True
565
+
566
+ if all(map(lambda x: x is not None, [trader_address])):
567
+ # If the topic is maximally set, we have a specific leaf
568
+ # we are querying, and can retrieve it from the SMT
569
+ # accordingly
570
+
571
+ # Return a snapshot with a single Trader leaf item
572
+ trader_key: TraderKey = TraderKey(trader_address)
573
+ return [{"t": topic_string(trader_key), "c": self.smt.trader(trader_key)}]
574
+
575
+ # Return a snapshot containing the Trader leaves obtained
576
+ return [
577
+ {
578
+ "t": topic_string(trader_key),
579
+ "c": trader,
580
+ }
581
+ for trader_key, trader in self.smt.all_traders()
582
+ if encompasses_key(trader_key)
583
+ ]
584
+
585
+ def get_strategy_snapshot(
586
+ self, trader_address: Optional[str], strategy_id_hash: Optional[str]
587
+ ) -> list[dict]:
588
+ """
589
+ Get a snapshot of Strategy leaves given a particular key.
590
+ Parameters
591
+ ----------
592
+ trader_address : str
593
+ Trader address
594
+ strategy_id_hash : str
595
+ Strategy ID hash
596
+ """
597
+
598
+ def topic_string(strategy_key: StrategyKey):
599
+ return f"{'/'.join(filter(None, ['STATE', 'STRATEGY', strategy_key.trader_address, strategy_key.strategy_id_hash]))}/"
600
+
601
+ def encompasses_key(against_key: StrategyKey):
602
+ if strategy_id_hash is not None:
603
+ return (
604
+ against_key.trader_address == trader_address
605
+ and against_key.strategy_id_hash == strategy_id_hash
606
+ )
607
+ elif trader_address is not None:
608
+ return against_key.trader_address == trader_address
609
+ return True
610
+
611
+ if all(map(lambda x: x is not None, [trader_address, strategy_id_hash])):
612
+ # If the topic is maximally set, we have a specific leaf
613
+ # we are querying, and can retrieve it from the SMT
614
+ # accordingly
615
+ strategy_key: StrategyKey = StrategyKey(
616
+ trader_address, strategy_id_hash)
617
+ # Return a snapshot with a single Strategy leaf item
618
+ return [
619
+ {"t": topic_string(strategy_key),
620
+ "c": self.smt.strategy(strategy_key)}
621
+ ]
622
+
623
+ # Return a snapshot containing the Trader leaves obtained
624
+ return [
625
+ {
626
+ "t": topic_string(strategy_key),
627
+ "c": strategy,
628
+ }
629
+ for strategy_key, strategy in self.smt.all_strategies()
630
+ if encompasses_key(strategy_key)
631
+ ]
632
+
633
+ def get_position_snapshot(
634
+ self,
635
+ symbol: Optional[ProductSymbol],
636
+ trader_address: Optional[str],
637
+ strategy_id_hash: Optional[str],
638
+ ) -> list[dict]:
639
+ """
640
+ Get a snapshot of Position leaves given a particular key.
641
+ Parameters
642
+ ----------
643
+ symbol : ProductSymbol
644
+ Product symbol
645
+ trader_address : str
646
+ Trader address
647
+ strategy_id_hash : str
648
+ Strategy ID hash
649
+ """
650
+
651
+ def topic_string(position_key: PositionKey):
652
+ return f"{'/'.join(filter(None, ['STATE', 'POSITION', position_key.symbol, position_key.trader_address, position_key.strategy_id_hash]))}/"
653
+
654
+ def encompasses_key(against_key: PositionKey):
655
+ if strategy_id_hash is not None:
656
+ return (
657
+ against_key.symbol == symbol
658
+ and against_key.trader_address == trader_address
659
+ and against_key.strategy_id_hash == strategy_id_hash
660
+ )
661
+ elif trader_address is not None:
662
+ return (
663
+ against_key.symbol == symbol
664
+ and against_key.trader_address == trader_address
665
+ )
666
+ elif symbol is not None:
667
+ return against_key.symbol == symbol
668
+ return True
669
+
670
+ if all(
671
+ map(lambda x: x is not None, [
672
+ symbol, trader_address, strategy_id_hash])
673
+ ):
674
+ # If the topic is maximally set, we have a specific leaf
675
+ # we are querying, and can retrieve it from the SMT
676
+ # accordingly
677
+ position_key: PositionKey = PositionKey(
678
+ trader_address, strategy_id_hash, symbol
679
+ )
680
+ # Return a snapshot with a single Position leaf item
681
+ return [
682
+ {"t": topic_string(position_key),
683
+ "c": self.smt.position(position_key)}
684
+ ]
685
+
686
+ # Return a snapshot containing the Position leaves obtained
687
+ return [
688
+ {
689
+ "t": topic_string(position_key),
690
+ "c": position,
691
+ }
692
+ for position_key, position in self.smt.all_positions()
693
+ if encompasses_key(position_key)
694
+ ]
695
+
696
+ def get_book_order_snapshot(
697
+ self,
698
+ symbol: Optional[ProductSymbol],
699
+ order_hash: Optional[str],
700
+ trader_address: Optional[str],
701
+ strategy_id_hash: Optional[str],
702
+ ) -> list[dict]:
703
+ """
704
+ Get a snapshot of BookOrder leaves given a particular key.
705
+ Parameters
706
+ ----------
707
+ symbol : ProductSymbol
708
+ Product symbol
709
+ order_hash : str
710
+ Order hash
711
+ trader_address : str
712
+ Trader address
713
+ strategy_id_hash : str
714
+ Strategy ID hash
715
+ """
716
+
717
+ def topic_string(
718
+ book_order_key: BookOrderKey, trader_address: str, strategy_id_hash: str
719
+ ):
720
+ return f"{'/'.join(filter(None, ['STATE', 'BOOK_ORDER', book_order_key.symbol, book_order_key.order_hash, trader_address, strategy_id_hash]))}/"
721
+
722
+ def encompasses_key(
723
+ against_key: BookOrderKey,
724
+ against_trader_address: str,
725
+ against_strategy_id_hash: str,
726
+ ):
727
+ if strategy_id_hash is not None:
728
+ return (
729
+ against_key.symbol == symbol
730
+ and against_trader_address == trader_address
731
+ and against_strategy_id_hash == strategy_id_hash
732
+ )
733
+ elif trader_address is not None:
734
+ return (
735
+ against_key.symbol == symbol
736
+ and against_trader_address == trader_address
737
+ )
738
+ elif symbol is not None:
739
+ return against_key.symbol == symbol
740
+ return True
741
+
742
+ if all(
743
+ map(
744
+ lambda x: x is not None,
745
+ [symbol, order_hash, trader_address, strategy_id_hash],
746
+ )
747
+ ):
748
+ # If the topic is maximally set, we have a specific leaf
749
+ # we are querying, and can retrieve it from the SMT
750
+ # accordingly
751
+ book_order_key: BookOrderKey = BookOrderKey(symbol, order_hash)
752
+ # Return a snapshot with a single Position leaf item
753
+ return [
754
+ {
755
+ "t": topic_string(book_order_key, trader_address, strategy_id_hash),
756
+ "c": self.smt.book_order(book_order_key),
757
+ }
758
+ ]
759
+
760
+ # Return a snapshot containing the BookOrder leaves obtained
761
+ return [
762
+ {
763
+ "t": topic_string(
764
+ book_order_key,
765
+ book_order.trader_address,
766
+ book_order.strategy_id_hash,
767
+ ),
768
+ "c": book_order,
769
+ }
770
+ for book_order_key, book_order in self.smt.all_book_orders()
771
+ if encompasses_key(
772
+ book_order_key,
773
+ book_order.trader_address,
774
+ book_order.strategy_id_hash,
775
+ )
776
+ ]
777
+
778
+ def get_insurance_fund_snapshot(self) -> list[dict]:
779
+ """
780
+ Get a snapshot of the organic InsuranceFund leaf.
781
+ """
782
+
783
+ # Return a snapshot containing the organic InsuranceFund
784
+ return [
785
+ {
786
+ "t": "STATE/INSURANCE_FUND/",
787
+ "c": self.smt.insurance_fund(InsuranceFundKey()),
788
+ }
789
+ ]
790
+
791
+ # ************** WEBSOCKET FUNCTIONALITY ************** #
792
+
793
+ async def _handle_tx_log_update_message(self, message: dict) -> None:
794
+ """
795
+ Handle the transaction log message received from the Trader
796
+ API upon subscription. This will be either the Partial (includes
797
+ the state snapshot SMT data as of the most recent
798
+ checkpoint and the transaction log entries from that point
799
+ up until now) or Update messages (streaming messages of
800
+ individual transaction log entries from this point onwards).
801
+ These messages are parsed to get things into the same format
802
+ such that the Auditor can be used as-is by the integration
803
+ tests as well.
804
+
805
+ Parameters
806
+ ----------
807
+ message : dict
808
+ Transaction log update message
809
+ """
810
+
811
+ if message["t"] == WebsocketEventType.SNAPSHOT:
812
+ # If transaction log message is of type Snapshot, we will
813
+ # need to process the snapshot of state leaves as of the
814
+ # most recent checkpoint and
815
+
816
+ # Extract the state snapshot leaves, which is the state
817
+ # snapshot as of the most recent completed checkpoint
818
+ # at the time of subscribing to the transaction log
819
+ parsed_state_snapshot = message["c"]["leaves"]
820
+
821
+ # Process the state snapshot
822
+ self.process_state_snapshot(
823
+ int(message["c"]["epochId"]),
824
+ parsed_state_snapshot,
825
+ )
826
+
827
+ # Mark that we've received a snapshot
828
+ self._snapshot_received = True
829
+
830
+ else:
831
+ # Parse the transaction log entries suitable for the
832
+ # Auditor such that it can be reused as-is by the
833
+ # integration tests
834
+ parsed_tx_log_entry = get_parsed_tx_log_entry(message["c"])
835
+
836
+ if self.first_head:
837
+ # If this is the first tx log entry of the head response
838
+
839
+ # Check if we're in epoch < 2 and haven't received a snapshot yet
840
+ if not self._snapshot_received:
841
+ logger.warning(
842
+ f"Received Head message in epoch {parsed_tx_log_entry['epochId']} without snapshot. Restarting connection to wait for epoch >= 2..."
843
+ )
844
+ raise RuntimeError(
845
+ f"No snapshot available in epoch {parsed_tx_log_entry['epochId']} < 2, restarting..."
846
+ )
847
+
848
+ # Initialize the current local state root hash to the SMT's root
849
+ # hash after having loaded the state snapshot
850
+ self.current_state_root_hash = f"0x{self.smt.root().as_bytes().hex()}"
851
+ self.current_batch_state_root_hash = self.current_state_root_hash
852
+
853
+ self.latest_batch_id = parsed_tx_log_entry["batchId"]
854
+
855
+ self.first_head = False
856
+
857
+ # Process the transaction log entries
858
+ self.process_tx_log_event(
859
+ parsed_tx_log_entry, message["t"] == WebsocketEventType.HEAD
860
+ )
861
+
862
+ async def api_auditor_consumer_handler(
863
+ self, websocket: WebSocketClientProtocol, path: str
864
+ ):
865
+ """
866
+ API <> Auditor consumer handler for messages that are received
867
+ by the Auditor from the API.
868
+
869
+ Parameters
870
+ ----------
871
+ websocket : WebSocketServerProtocol
872
+ The WS connection instance between API and Auditor
873
+ """
874
+
875
+ async def _inner_messages(
876
+ ws: websockets.WebSocketClientProtocol,
877
+ ) -> AsyncIterable[str]:
878
+ try:
879
+ while True:
880
+ try:
881
+ msg: str = await asyncio.wait_for(ws.recv(), timeout=30.0)
882
+ yield msg
883
+ except asyncio.TimeoutError:
884
+ try:
885
+ pong_waiter = await ws.ping()
886
+ await asyncio.wait_for(pong_waiter, timeout=30.0)
887
+ except asyncio.TimeoutError:
888
+ raise
889
+ except asyncio.TimeoutError:
890
+ print("WebSocket ping timed out. Going to reconnect...")
891
+ return
892
+ except websockets.ConnectionClosed:
893
+ return
894
+ finally:
895
+ await ws.close()
896
+
897
+ # Loop through messages as they come in on the WebSocket
898
+ async for message in _inner_messages(websocket):
899
+ # JSON-serialize the inbound message
900
+ data = json.loads(message)
901
+
902
+ if "t" not in data:
903
+ # Non topical data, such as rate-limiting message
904
+ continue
905
+
906
+ topic = data["t"]
907
+
908
+ if topic in ["Snapshot", "Head", "Tail"]:
909
+ # If the message is a TxLogUpdate, this is something
910
+ # that should be processed by the Auditor
911
+
912
+ # Handle transaction log message
913
+ await self._handle_tx_log_update_message(data)
914
+
915
+ async def api_auditor_producer_handler(
916
+ self, websocket: WebSocketClientProtocol, path: str
917
+ ):
918
+ """
919
+ API <> Auditor producer handler for messages that are sent
920
+ from the Auditor to the API.
921
+
922
+ Parameters
923
+ ----------
924
+ websocket : WebSocketServerProtocol
925
+ The WS connection instance between API and Auditor
926
+ """
927
+
928
+ # Start things off with a subscription to the TxLogUpdate
929
+ # channel on the API to receive a snapshot and streaming
930
+ # updates to the transaction log
931
+ tx_log_update_subscription = WebsocketMessage(
932
+ "SubscribeMarket", {"events": ["TxLogUpdate"]}
933
+ )
934
+ self.api_auditor_queue.put_nowait(tx_log_update_subscription)
935
+
936
+ try:
937
+ while True:
938
+ # Receive the oldest message (FIFO) in the queue and
939
+ # send after serialization to the API
940
+ message = await self.api_auditor_queue.get()
941
+ await websocket.send(ComplexOutputEncoder().encode(message))
942
+ except websockets.ConnectionClosed:
943
+ print("Connection has been closed (api_auditor_producer_handler)")
944
+
945
+ async def api_auditor_server(self):
946
+ """
947
+ sets up the DerivaDEX API <> Auditor server with consumer and
948
+ producer tasks. The consumer is when the Auditor receives
949
+ messages from the API, and the producer is when the Auditor
950
+ sends messages to the API.
951
+ """
952
+
953
+ def _generate_uri_token():
954
+ """
955
+ Generate URI token to connect to the API
956
+ """
957
+
958
+ # Construct and return WS connection url with format
959
+ return f"{self.webserver_url.replace('http','ws',1)}/v2/txlog"
960
+
961
+ while True:
962
+ try:
963
+ # set up a WS context connection given a specific URI
964
+ async with websockets.connect(
965
+ _generate_uri_token(),
966
+ max_size=2**32,
967
+ ping_timeout=None,
968
+ ) as websocket_client:
969
+ try:
970
+ # set up the consumer
971
+ consumer_task = asyncio.ensure_future(
972
+ self.api_auditor_consumer_handler(
973
+ websocket_client, None)
974
+ )
975
+
976
+ # set up the producer
977
+ producer_task = asyncio.ensure_future(
978
+ self.api_auditor_producer_handler(
979
+ websocket_client, None)
980
+ )
981
+
982
+ # These should essentially run forever unless one of them
983
+ # is stopped for some reason
984
+ done, pending = await asyncio.wait(
985
+ [consumer_task, producer_task],
986
+ return_when=asyncio.FIRST_COMPLETED,
987
+ )
988
+ for task in pending:
989
+ task.cancel()
990
+ finally:
991
+ logger.info(
992
+ f"API <> Auditor server outer loop restarting")
993
+
994
+ await asyncio.sleep(30.0)
995
+
996
+ self._reset()
997
+ continue
998
+
999
+ except asyncio.CancelledError:
1000
+ raise
1001
+ except Exception as e:
1002
+ print(
1003
+ f"Unexpected error with WebSocket connection: {e}. Retrying after 30 seconds...",
1004
+ )
1005
+ await asyncio.sleep(30.0)
1006
+
1007
+ # Reset Auditor state upon reconnection
1008
+ self._reset()
1009
+
1010
+ def shutdown_operators(self):
1011
+ node_urls = [
1012
+ url.strip("/")
1013
+ for url in requests.get(f"{self.webserver_url}/v2/status")
1014
+ .json()["raftMetrics"]["nodes"]
1015
+ .values()
1016
+ ]
1017
+
1018
+ for url in node_urls:
1019
+ r = requests.get(f"{url}/v2/shutdown")
1020
+ logger.info(
1021
+ f"shutting down operator node at {url} succeeded: {r.ok}")
1022
+
1023
+ # ************** ASYNCIO ENTRYPOINT ************** #
1024
+
1025
+ async def main(self):
1026
+ """
1027
+ Main entry point for the Auditor. It sets up the various
1028
+ coroutines to run on the event loop - API <> auditor WS server.
1029
+ """
1030
+
1031
+ # Initialize parameters inside event loop
1032
+ self._reset()
1033
+
1034
+ await asyncio.gather(self.api_auditor_server())