BlockState working in memory with basic event triggers
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
venv/
|
||||||
|
*secret*
|
||||||
|
dexorder.toml
|
||||||
|
./contract
|
||||||
|
.idea
|
||||||
111
alembic.ini
Normal file
111
alembic.ini
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# path to migration scripts
|
||||||
|
script_location = alembic
|
||||||
|
|
||||||
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
|
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||||
|
# for all available tokens
|
||||||
|
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# sys.path path, will be prepended to sys.path if present.
|
||||||
|
# defaults to the current working directory.
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
# timezone to use when rendering the date within the migration file
|
||||||
|
# as well as the filename.
|
||||||
|
# If specified, requires the python-dateutil library that can be
|
||||||
|
# installed by adding `alembic[tz]` to the pip requirements
|
||||||
|
# string value is passed to dateutil.tz.gettz()
|
||||||
|
# leave blank for localtime
|
||||||
|
# timezone =
|
||||||
|
|
||||||
|
# max length of characters to apply to the
|
||||||
|
# "slug" field
|
||||||
|
# truncate_slug_length = 40
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
# set to 'true' to allow .pyc and .pyo files without
|
||||||
|
# a source .py file to be detected as revisions in the
|
||||||
|
# versions/ directory
|
||||||
|
# sourceless = false
|
||||||
|
|
||||||
|
# version location specification; This defaults
|
||||||
|
# to alembic/versions. When using multiple version
|
||||||
|
# directories, initial revisions must be specified with --version-path.
|
||||||
|
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||||
|
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||||
|
|
||||||
|
# version path separator; As mentioned above, this is the character used to split
|
||||||
|
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||||
|
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||||
|
# Valid values for version_path_separator are:
|
||||||
|
#
|
||||||
|
# version_path_separator = :
|
||||||
|
# version_path_separator = ;
|
||||||
|
# version_path_separator = space
|
||||||
|
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||||
|
|
||||||
|
# set to 'true' to search source files recursively
|
||||||
|
# in each "version_locations" directory
|
||||||
|
# new in Alembic version 1.10
|
||||||
|
# recursive_version_locations = false
|
||||||
|
|
||||||
|
# the output encoding used when revision files
|
||||||
|
# are written from script.py.mako
|
||||||
|
# output_encoding = utf-8
|
||||||
|
|
||||||
|
# this is set programatically in the env.py
|
||||||
|
sqlalchemy.url =
|
||||||
|
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
# post_write_hooks defines scripts or Python functions that are run
|
||||||
|
# on newly generated revision scripts. See the documentation for further
|
||||||
|
# detail and examples
|
||||||
|
|
||||||
|
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||||
|
# hooks = black
|
||||||
|
# black.type = console_scripts
|
||||||
|
# black.entrypoint = black
|
||||||
|
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
79
alembic/env.py
Normal file
79
alembic/env.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from sqlalchemy import engine_from_config
|
||||||
|
from sqlalchemy import pool
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# DEXORDER SETUP
|
||||||
|
from sys import path
|
||||||
|
path.append('src')
|
||||||
|
import dexorder.db.model
|
||||||
|
target_metadata = dexorder.db.model.Base.metadata
|
||||||
|
config.set_main_option('sqlalchemy.url', dexorder.config.db_url)
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection, target_metadata=target_metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
27
alembic/script.py.mako
Normal file
27
alembic/script.py.mako
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import dexorder.db
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
9
requirements.txt
Normal file
9
requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
sqlalchemy~=2.0.20
|
||||||
|
alembic~=1.11.3
|
||||||
|
omegaconf~=2.3.0
|
||||||
|
web3==6.9.0
|
||||||
|
psycopg2-binary
|
||||||
|
orjson~=3.9.7
|
||||||
|
sortedcontainers
|
||||||
|
hexbytes~=0.3.1
|
||||||
|
defaultlist~=1.0.0
|
||||||
21
src/dexorder/__init__.py
Normal file
21
src/dexorder/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# NARG is used in argument defaults to mean "not specified" rather than "specified as None"
|
||||||
|
class _NARG:
|
||||||
|
def __bool__(self): return False
|
||||||
|
NARG = _NARG()
|
||||||
|
ADDRESS_0 = '0x0000000000000000000000000000000000000000'
|
||||||
|
WEI = 1
|
||||||
|
GWEI = 1_000_000_000
|
||||||
|
ETH = 1_000_000_000_000_000_000
|
||||||
|
|
||||||
|
# noinspection PyProtectedMember
|
||||||
|
from .util.cwd import _cwd
|
||||||
|
_cwd() # do this first so that config has the right current working directory
|
||||||
|
|
||||||
|
# ordering here is important!
|
||||||
|
from .base.chain import Blockchain # the singletons are loaded into the dexorder.blockchain.* namespace
|
||||||
|
from .util import async_yield
|
||||||
|
from .base.fixed import Fixed2, FixedDecimals, Dec18
|
||||||
|
from .configuration import config
|
||||||
|
from .base.account import Account # must come before context
|
||||||
|
from .base.context import ctx
|
||||||
|
from .base.token import Token, tokens
|
||||||
0
src/dexorder/base/__init__.py
Normal file
0
src/dexorder/base/__init__.py
Normal file
66
src/dexorder/base/account.py
Normal file
66
src/dexorder/base/account.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
from typing import Union, Optional
|
||||||
|
|
||||||
|
import eth_account
|
||||||
|
from eth_account.signers.local import LocalAccount
|
||||||
|
from web3.middleware import construct_sign_and_send_raw_middleware
|
||||||
|
|
||||||
|
from dexorder import NARG, config
|
||||||
|
|
||||||
|
|
||||||
|
# this is just here for typing the extra .name. the __new__() function returns an eth_account...LocalAccount
|
||||||
|
# we do it this way because web3py expects a LocalAccount object but we cannot construct one directly with a super()
|
||||||
|
# call but must instead use a factory :(
|
||||||
|
class Account (LocalAccount):
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
# noinspection PyInitNewSignature
|
||||||
|
def get(account:[Union,str]=NARG) -> Optional[LocalAccount]:
|
||||||
|
if account is NARG:
|
||||||
|
account = config.account
|
||||||
|
if type(account) is not str:
|
||||||
|
return account
|
||||||
|
|
||||||
|
key_str = config.accounts.get(account, account)
|
||||||
|
try:
|
||||||
|
local_account = eth_account.Account.from_key(key_str)
|
||||||
|
return Account(local_account, key_str, account)
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
# was the key missing a leading '0x'?
|
||||||
|
fixed = '0x' + key_str
|
||||||
|
local_account = eth_account.Account.from_key(fixed)
|
||||||
|
print(f'WARNING: account "{account}" is missing a leading "0x"')
|
||||||
|
return Account(local_account, fixed, account)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
# was the key an integer posing as a string?
|
||||||
|
converted = f'{int(key_str):#0{66}x}'
|
||||||
|
local_account = eth_account.Account.from_key(converted)
|
||||||
|
print(f'WARNING: account "{account}" is set as an integer instead of a string. Converted to: {converted}')
|
||||||
|
return Account(local_account, converted, account)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
raise ValueError(f'Could not construct account for name "{account}"')
|
||||||
|
|
||||||
|
def __init__(self, local_account: LocalAccount, key_str, name: str): # todo chain_id?
|
||||||
|
super().__init__(local_account._key_obj, local_account._publicapi) # from digging into the source code
|
||||||
|
self.name = name
|
||||||
|
self.transaction_counter = 0 # used by GasHandler to detect when new transactions were fired
|
||||||
|
self.key_str = key_str
|
||||||
|
self.signing_middleware = construct_sign_and_send_raw_middleware(self)
|
||||||
|
|
||||||
|
def attach(self, w3):
|
||||||
|
w3.eth.default_account = self.address
|
||||||
|
try:
|
||||||
|
w3.middleware_onion.remove('account_signer')
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
w3.middleware_onion.add(self.signing_middleware, 'account_signer')
|
||||||
|
|
||||||
|
def balance(self):
|
||||||
|
return ctx.w3.eth.get_balance(self.address)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
220
src/dexorder/base/blockstate.py
Normal file
220
src/dexorder/base/blockstate.py
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
from collections import defaultdict
|
||||||
|
from contextvars import ContextVar
|
||||||
|
from logging import Logger
|
||||||
|
from typing import Union, TypeVar, Generic, Any
|
||||||
|
|
||||||
|
from sortedcontainers import SortedList
|
||||||
|
|
||||||
|
from dexorder import NARG
|
||||||
|
from dexorder.db.model.block import Block
|
||||||
|
|
||||||
|
log = Logger('dexorder.blockstate')
|
||||||
|
|
||||||
|
|
||||||
|
class BlockState:
|
||||||
|
DELETE = object()
|
||||||
|
|
||||||
|
by_chain: dict[int, 'BlockState'] = {}
|
||||||
|
|
||||||
|
"""
|
||||||
|
Since recent blocks can be part of temporary forks, we need to be able to undo certain operations if they were part of a reorg. Instead of implementing
|
||||||
|
undo, we recover state via snapshot plus replay of recent diffs. When old blocks become low enough in the blockheight they may be considered canonical
|
||||||
|
at which point the deltas may be reliably incorporated into a new snapshot or rolling permanent collection. BlockState manages separate memory areas
|
||||||
|
for every block, per-block state that defaults to its parent's state, up the ancestry tree to the root. State clients may read the state for their block,
|
||||||
|
applying any diffs from the root state to the target block.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def cur() -> 'BlockState':
|
||||||
|
return _cur.get()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def set_cur(value: 'BlockState'):
|
||||||
|
_cur.set(value)
|
||||||
|
|
||||||
|
def __init__(self, root_block: Block, root_state: dict):
|
||||||
|
self.root_block: Block = root_block
|
||||||
|
self.root_state: dict = root_state
|
||||||
|
self.by_height: SortedList[tuple[int, Block]] = SortedList(key=lambda x: x[0])
|
||||||
|
self.by_hash: dict[bytes, Block] = {root_block.hash: root_block}
|
||||||
|
self.diffs: dict[bytes, dict[Any, dict[Any, Union[Any, BlockState.DELETE]]]] = defaultdict(dict) # by series
|
||||||
|
self.ancestors: dict[bytes, Block] = {}
|
||||||
|
BlockState.by_chain[root_block.chain] = self
|
||||||
|
|
||||||
|
def add_block(self, block: Block) -> Union[int, Block, None]:
|
||||||
|
"""
|
||||||
|
If block is the same age as root_height or older, it is ignored and None is returned. Otherwise, returns the found parent block if available
|
||||||
|
or else self.root_height.
|
||||||
|
The ancestor block is set in the ancestors dictionary and any state updates to block are considered to have occured between the registered ancestor
|
||||||
|
block and the given block. This could be an interval of many blocks, and the ancestor does not need to be the block's immediate parent.
|
||||||
|
"""
|
||||||
|
# check height
|
||||||
|
height_diff = block.height - self.root_block.height
|
||||||
|
if height_diff <= 0:
|
||||||
|
log.debug(f'IGNORING old block {block}')
|
||||||
|
return None
|
||||||
|
if block.hash not in self.by_hash:
|
||||||
|
self.by_hash[block.hash] = block
|
||||||
|
self.by_height.add((block.height, block))
|
||||||
|
log.debug(f'new block state {block}')
|
||||||
|
parent = self.by_hash.get(block.parent)
|
||||||
|
if parent is None:
|
||||||
|
self.ancestors[block.hash] = self.root_block
|
||||||
|
return self.root_block.height
|
||||||
|
else:
|
||||||
|
self.ancestors[block.hash] = parent
|
||||||
|
return parent
|
||||||
|
|
||||||
|
def promote_root(self, block):
|
||||||
|
assert block.hash in self.by_hash
|
||||||
|
diffs = self.collect_diffs(block)
|
||||||
|
BlockState.apply_diffs(self.root_state, diffs)
|
||||||
|
del self.by_hash[self.root_block.hash]
|
||||||
|
while self.by_height and self.by_height[0][0] <= block.height:
|
||||||
|
height, dead = self.by_height.pop(0)
|
||||||
|
if dead is not block:
|
||||||
|
try:
|
||||||
|
del self.by_hash[dead.hash]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
del self.diffs[dead.hash]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
del self.ancestors[dead.hash]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
self.root_block = block
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def apply_diffs(obj, diffs):
|
||||||
|
for series_key, series in diffs.items():
|
||||||
|
for key, value in series.items():
|
||||||
|
if value is BlockState.DELETE:
|
||||||
|
try:
|
||||||
|
del obj[series_key][key]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
series_obj = obj.get(series_key)
|
||||||
|
if series_obj is None:
|
||||||
|
obj[series_key] = series_obj = {}
|
||||||
|
series_obj[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
def collect_diffs(self, block, series_key=NARG):
|
||||||
|
diffs = {}
|
||||||
|
while block is not self.root_block:
|
||||||
|
block_diffs = self.diffs.get(block.hash)
|
||||||
|
if block_diffs is not None:
|
||||||
|
if series_key is NARG:
|
||||||
|
for s_key, series in block_diffs.items():
|
||||||
|
series_diffs = diffs.get(s_key)
|
||||||
|
if series_diffs is None:
|
||||||
|
series_diffs = diffs[s_key] = {}
|
||||||
|
for k, v in series.items():
|
||||||
|
series_diffs.setdefault(k, v)
|
||||||
|
else:
|
||||||
|
series = block_diffs.get(series_key)
|
||||||
|
if series is not None:
|
||||||
|
for k, v in series.items():
|
||||||
|
diffs.setdefault(k, v)
|
||||||
|
block = self.ancestors[block.hash]
|
||||||
|
return diffs
|
||||||
|
|
||||||
|
|
||||||
|
_cur = ContextVar[BlockState]('BlockState.cur')
|
||||||
|
|
||||||
|
|
||||||
|
T = TypeVar('T')
|
||||||
|
|
||||||
|
|
||||||
|
class BlockDict(Generic[T]):
|
||||||
|
|
||||||
|
def __init__(self, series_key):
|
||||||
|
self.series_key = series_key
|
||||||
|
|
||||||
|
def __setitem__(self, item, value):
|
||||||
|
BlockDict.setitem(self.series_key, item, value)
|
||||||
|
|
||||||
|
def __getitem__(self, item):
|
||||||
|
return BlockDict.getitem(self.series_key, item)
|
||||||
|
|
||||||
|
def __delitem__(self, item):
|
||||||
|
BlockDict.delitem(self.series_key, item)
|
||||||
|
|
||||||
|
def __contains__(self, item):
|
||||||
|
return BlockDict.contains(self.series_key, item)
|
||||||
|
|
||||||
|
def add(self, item):
|
||||||
|
""" set-like semantics. the item key is added with a value of None. """
|
||||||
|
BlockDict.setitem(self.series_key, item, None)
|
||||||
|
|
||||||
|
def items(self):
|
||||||
|
return BlockDict.iter_items(self.series_key)
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def setitem(series_key, item, value):
|
||||||
|
state = BlockState.cur()
|
||||||
|
block = Block.cur()
|
||||||
|
if block.height > state.root_block.height:
|
||||||
|
diffs = state.diffs[block.hash]
|
||||||
|
series = diffs.get(series_key)
|
||||||
|
if series is None:
|
||||||
|
series = diffs[series_key] = {}
|
||||||
|
else:
|
||||||
|
series = state.root_state.get(series_key)
|
||||||
|
if series is None:
|
||||||
|
series = state.root_state[series_key] = {}
|
||||||
|
series[item] = value
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def getitem(series_key, item):
|
||||||
|
state = BlockState.cur()
|
||||||
|
block = Block.cur()
|
||||||
|
while block.height > state.root_block.height:
|
||||||
|
diffs = state.diffs.get(block.hash)
|
||||||
|
if diffs is not None:
|
||||||
|
series = diffs.get(series_key)
|
||||||
|
if series is not None:
|
||||||
|
value = series.get(item, NARG)
|
||||||
|
if value is BlockState.DELETE:
|
||||||
|
raise KeyError
|
||||||
|
if value is not NARG:
|
||||||
|
return value
|
||||||
|
block = state.ancestors[block.hash]
|
||||||
|
if block is not state.root_block:
|
||||||
|
raise ValueError('Orphaned block is invalid',Block.cur().hash)
|
||||||
|
root_series = state.root_state.get(series_key)
|
||||||
|
if root_series is not None:
|
||||||
|
value = root_series.get(item, NARG)
|
||||||
|
if value is BlockState.DELETE:
|
||||||
|
raise KeyError
|
||||||
|
if value is not NARG:
|
||||||
|
return value
|
||||||
|
raise KeyError
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def delitem(series_key, item):
|
||||||
|
BlockDict.setitem(series_key, item, BlockState.DELETE)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def contains(series_key, item):
|
||||||
|
try:
|
||||||
|
BlockDict.getitem(series_key, item)
|
||||||
|
return True
|
||||||
|
except KeyError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def iter_items(series_key):
|
||||||
|
state = BlockState.cur()
|
||||||
|
block = Block.cur()
|
||||||
|
root = state.root_state.get(series_key,{})
|
||||||
|
diffs = state.collect_diffs(block, series_key)
|
||||||
|
# first output recent changes in the diff obj
|
||||||
|
yield from ((k,v) for k,v in diffs.items() if v is not BlockState.DELETE)
|
||||||
|
# then all the items not diffed
|
||||||
|
yield from ((k,v) for k,v in root.items() if k not in diffs)
|
||||||
50
src/dexorder/base/chain.py
Normal file
50
src/dexorder/base/chain.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
from contextvars import ContextVar
|
||||||
|
|
||||||
|
|
||||||
|
class Blockchain:
|
||||||
|
@staticmethod
|
||||||
|
def cur() -> 'Blockchain':
|
||||||
|
return _cur.get()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def set_cur(value: 'Blockchain'):
|
||||||
|
_cur.set(value)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def for_id(chain_id):
|
||||||
|
result = Blockchain._instances_by_id.get(chain_id)
|
||||||
|
if result is None:
|
||||||
|
result = Blockchain(chain_id, 'Unknown')
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def for_name(chain_name):
|
||||||
|
return Blockchain._instances_by_name[chain_name]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get(name_or_id):
|
||||||
|
return Blockchain.for_name(name_or_id) if type(name_or_id) is str else Blockchain.for_id(name_or_id)
|
||||||
|
|
||||||
|
def __init__(self, chain_id, name):
|
||||||
|
self.chain_id = chain_id
|
||||||
|
self.name = name
|
||||||
|
Blockchain._instances_by_id[chain_id] = self
|
||||||
|
Blockchain._instances_by_name[name] = self
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
_instances_by_id = {}
|
||||||
|
_instances_by_name = {}
|
||||||
|
|
||||||
|
|
||||||
|
# https://chainlist.org/
|
||||||
|
|
||||||
|
Ethereum = Blockchain(1, 'Ethereum')
|
||||||
|
Goerli = Blockchain(5, 'Goerli')
|
||||||
|
Polygon = Blockchain(137, 'Polygon') # POS not zkEVM
|
||||||
|
Mumbai = Blockchain(80001, 'Mumbai')
|
||||||
|
BSC = Blockchain(56, 'BSC')
|
||||||
|
Arbitrum = ArbitrumOne = Blockchain(42161, 'ArbitrumOne')
|
||||||
|
|
||||||
|
_cur = ContextVar[Blockchain]('Blockchain.cur')
|
||||||
24
src/dexorder/base/event_manager.py
Normal file
24
src/dexorder/base/event_manager.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from eth_utils import keccak
|
||||||
|
|
||||||
|
from dexorder.base.blockstate import BlockDict
|
||||||
|
|
||||||
|
|
||||||
|
class EventManager:
|
||||||
|
def __init__(self):
|
||||||
|
self.all_topics = set()
|
||||||
|
self.triggers:dict[str,BlockDict] = {}
|
||||||
|
|
||||||
|
def add_handler(self, topic: str, callback):
|
||||||
|
if not topic.startswith('0x'):
|
||||||
|
topic = '0x'+keccak(text=topic).hex().lower()
|
||||||
|
triggers = self.triggers.get(topic)
|
||||||
|
if triggers is None:
|
||||||
|
triggers = self.triggers[topic] = BlockDict(topic)
|
||||||
|
triggers.add(callback)
|
||||||
|
self.all_topics.add(topic)
|
||||||
|
|
||||||
|
def handle_logs(self, logs):
|
||||||
|
for log in logs:
|
||||||
|
for callback, _ in self.triggers.get(log.topics[0].hex(), []).items():
|
||||||
|
callback(log)
|
||||||
|
|
||||||
121
src/dexorder/base/fixed.py
Normal file
121
src/dexorder/base/fixed.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# binary fixed point math
|
||||||
|
from _decimal import Decimal
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
|
||||||
|
class FixedDecimals:
|
||||||
|
def __init__(self, decimals):
|
||||||
|
self._denom = Decimal(10) ** Decimal(decimals)
|
||||||
|
|
||||||
|
def dec(self, amount: int) -> Decimal:
|
||||||
|
return Decimal(amount) / self._denom
|
||||||
|
|
||||||
|
def amount(self, decimal_like: [Decimal, int, str, float]) -> int:
|
||||||
|
return round(Decimal(decimal_like) * self._denom)
|
||||||
|
|
||||||
|
|
||||||
|
class Fixed2:
|
||||||
|
def __init__(self, int_value, denom_bits):
|
||||||
|
assert (int_value == int(int_value))
|
||||||
|
self.int_value = int_value
|
||||||
|
self.dbits = denom_bits
|
||||||
|
|
||||||
|
@property
|
||||||
|
def value(self):
|
||||||
|
return self.int_value / 2 ** self.dbits
|
||||||
|
|
||||||
|
def round(self, denom_bits):
|
||||||
|
if self.dbits > denom_bits:
|
||||||
|
int_value = round(self.int_value / 2 ** (self.dbits - denom_bits))
|
||||||
|
elif self.dbits < denom_bits:
|
||||||
|
int_value = self.int_value * 2 ** (denom_bits - self.dbits)
|
||||||
|
else:
|
||||||
|
int_value = self.int_value
|
||||||
|
return Fixed2(int_value, self.dbits)
|
||||||
|
|
||||||
|
def __format__(self, format_spec):
|
||||||
|
return self.value.__format__(format_spec) if format_spec.endswith('f') \
|
||||||
|
else self.int_value.__format__(format_spec[:-1])
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return str(self.int_value)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f'Fixed({self.int_value},{self.dbits})'
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
return hash(self.int_value) ^ hash(self.dbits)
|
||||||
|
|
||||||
|
def __int__(self) -> int:
|
||||||
|
return self.int_value
|
||||||
|
|
||||||
|
def __float__(self) -> float:
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
def __abs__(self) -> 'Fixed2':
|
||||||
|
return Fixed2(abs(self.int_value), self.dbits)
|
||||||
|
|
||||||
|
def __hex__(self) -> str:
|
||||||
|
return hex(self.int_value)
|
||||||
|
|
||||||
|
def __neg__(self) -> 'Fixed2':
|
||||||
|
return Fixed2(-self.int_value, self.dbits)
|
||||||
|
|
||||||
|
def __bool__(self) -> bool:
|
||||||
|
return bool(self.int_value)
|
||||||
|
|
||||||
|
def __add__(self, other: 'Fixed2') -> 'Fixed2':
|
||||||
|
dbits = max(self.dbits, other.dbits)
|
||||||
|
return Fixed2(self.round(dbits).int_value + other.round(dbits).int_value, dbits)
|
||||||
|
|
||||||
|
def __sub__(self, other: 'Fixed2') -> 'Fixed2':
|
||||||
|
dbits = max(self.dbits, other.dbits)
|
||||||
|
return Fixed2(self.round(dbits).int_value - other.round(dbits).int_value, dbits)
|
||||||
|
|
||||||
|
def __mul__(self, other: 'Fixed2') -> 'Fixed2':
|
||||||
|
dbits = max(self.dbits, other.dbits)
|
||||||
|
self_up = self.round(dbits)
|
||||||
|
other_up = other.round(dbits)
|
||||||
|
return Fixed2(self_up.int_value * other_up.int_value // 2 ** dbits, dbits)
|
||||||
|
|
||||||
|
def __floordiv__(self, other):
|
||||||
|
dbits = max(self.dbits, other.dbits)
|
||||||
|
self_up = self.round(dbits)
|
||||||
|
other_up = other.round(dbits)
|
||||||
|
return self_up.int_value // other_up.int_value
|
||||||
|
|
||||||
|
def __divmod__(self, other: 'Fixed2') -> Tuple['Fixed2', 'Fixed2']:
|
||||||
|
dbits = max(self.dbits, other.dbits)
|
||||||
|
self_up = self.round(dbits)
|
||||||
|
other_up = other.round(dbits)
|
||||||
|
div, mod = self_up.int_value.__divmod__(other_up.int_value)
|
||||||
|
return Fixed2(div, dbits), Fixed2(mod, dbits) # mod not supported
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return (self - other).int_value == 0
|
||||||
|
|
||||||
|
def __le__(self, other):
|
||||||
|
return (self - other).int_value <= 0
|
||||||
|
|
||||||
|
def __gt__(self, other):
|
||||||
|
return (self - other).int_value > 0
|
||||||
|
|
||||||
|
|
||||||
|
class Fixed2Type:
|
||||||
|
def __init__(self, denominator_bits):
|
||||||
|
self.dbits = denominator_bits
|
||||||
|
|
||||||
|
def __call__(self, int_value):
|
||||||
|
return Fixed2(int_value, self.dbits)
|
||||||
|
|
||||||
|
def from_string(self, str_value):
|
||||||
|
return self(round(Decimal(str_value) * 2 ** self.dbits))
|
||||||
|
|
||||||
|
def from_number(self, value):
|
||||||
|
return self(round(value) * 2 ** self.dbits)
|
||||||
|
|
||||||
|
|
||||||
|
X96 = Fixed2Type(96)
|
||||||
|
X128 = Fixed2Type(128)
|
||||||
|
|
||||||
|
Dec18 = FixedDecimals(18)
|
||||||
120
src/dexorder/base/token.py
Normal file
120
src/dexorder/base/token.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
from collections import defaultdict
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Mapped
|
||||||
|
from web3 import Web3
|
||||||
|
|
||||||
|
from dexorder import config, ctx, Blockchain, NARG, FixedDecimals, ADDRESS_0
|
||||||
|
from dexorder.blockchain import ByBlockchainDict
|
||||||
|
from dexorder.base.chain import Polygon, ArbitrumOne, Ethereum
|
||||||
|
from dexorder.contract import ContractProxy, abis
|
||||||
|
import dexorder.db.column as col
|
||||||
|
|
||||||
|
|
||||||
|
class Token (ContractProxy, FixedDecimals):
|
||||||
|
chain: Mapped[col.Blockchain]
|
||||||
|
address: Mapped[col.Address]
|
||||||
|
decimals: Mapped[col.Uint8]
|
||||||
|
name: Mapped[str]
|
||||||
|
symbol: Mapped[str]
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get(name_or_address:str, *, chain_id=None) -> 'Token':
|
||||||
|
try:
|
||||||
|
return tokens.get(name_or_address, default=NARG, chain_id=chain_id) # default=NARG will raise
|
||||||
|
except KeyError:
|
||||||
|
try:
|
||||||
|
# noinspection PyTypeChecker
|
||||||
|
return Web3.to_checksum_address(name_or_address)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f'Could not resolve token {name_or_address} for chain {ctx.chain_id}')
|
||||||
|
|
||||||
|
def __init__(self, chain_id, address, decimals, symbol, name, *, abi=None):
|
||||||
|
FixedDecimals.__init__(self, decimals)
|
||||||
|
if abi is None:
|
||||||
|
load = 'ERC20'
|
||||||
|
else:
|
||||||
|
load = None
|
||||||
|
abi = abis.get(abi,abi)
|
||||||
|
ContractProxy.__init__(self, address, load, abi=abi)
|
||||||
|
self.chain_id = chain_id
|
||||||
|
self.address = address
|
||||||
|
self.decimals = decimals
|
||||||
|
self.symbol = symbol
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
def balance(self, address: str = None) -> int:
|
||||||
|
if address is None:
|
||||||
|
address = ctx.address
|
||||||
|
return self.balanceOf(address)
|
||||||
|
|
||||||
|
def balance_dec(self, address: str = None) -> Decimal:
|
||||||
|
return self.dec(self.balance(address))
|
||||||
|
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.symbol
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'{self.symbol}({self.address},{self.decimals})'
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return self.chain_id == other.chain_id and self.address == other.address
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
return hash((self.chain_id,self.address))
|
||||||
|
|
||||||
|
|
||||||
|
class NativeToken (FixedDecimals):
|
||||||
|
""" Token-like but not a contract. """
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get( chain_id = None) -> 'NativeToken':
|
||||||
|
if chain_id is None:
|
||||||
|
chain_id = ctx.chain_id
|
||||||
|
return _native_tokens[chain_id]
|
||||||
|
|
||||||
|
def __init__(self, chain_id, decimals, symbol, name, *, wrapper_token = None):
|
||||||
|
self.chain_id = chain_id
|
||||||
|
self.address = ADDRESS_0 # todo i think there's actually an address? like 0x11 or something?
|
||||||
|
super().__init__(decimals)
|
||||||
|
self.symbol = symbol
|
||||||
|
self.name = name
|
||||||
|
self._wrapper_token = wrapper_token if wrapper_token is not None else _tokens_by_chain[chain_id]['W'+symbol]
|
||||||
|
|
||||||
|
def balance(self, address: str = None) -> int:
|
||||||
|
if address is None:
|
||||||
|
address = ctx.address
|
||||||
|
assert ctx.chain_id == self.chain_id
|
||||||
|
return ctx.w3.eth.get_balance(address)
|
||||||
|
|
||||||
|
def balance_dec(self, address: str = None) -> Decimal:
|
||||||
|
return self.dec(self.balance(address))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def wrapper(self) -> Token:
|
||||||
|
return self._wrapper_token
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return self.symbol
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# convert TokenConfigs to Tokens
|
||||||
|
_tokens_by_chain:dict[int,dict[str,Token]] = defaultdict(dict)
|
||||||
|
for _c in config.tokens:
|
||||||
|
_chain_id = Blockchain.get(_c.chain).chain_id
|
||||||
|
_tokens_by_chain[_chain_id][_c.symbol] = Token(_chain_id, _c.address, _c.decimals, _c.symbol, _c.name, abi=_c.abi)
|
||||||
|
|
||||||
|
_native_tokens: dict[int, NativeToken] = {
|
||||||
|
# Ethereum.chain_id: NativeToken(Ethereum.chain_id, 18, 'ETH', 'Ether'), # todo need WETH on Ethereum
|
||||||
|
# Polygon.chain_id: NativeToken(Polygon.chain_id, 18, 'MATIC', 'Polygon'),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _chain_id, _native in _native_tokens.items():
|
||||||
|
# noinspection PyTypeChecker
|
||||||
|
_tokens_by_chain[_chain_id][_native.symbol] = _native
|
||||||
|
|
||||||
|
tokens = ByBlockchainDict[Token](_tokens_by_chain)
|
||||||
|
|
||||||
0
src/dexorder/bin/__init__.py
Normal file
0
src/dexorder/bin/__init__.py
Normal file
50
src/dexorder/bin/executable.py
Normal file
50
src/dexorder/bin/executable.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import logging
|
||||||
|
from asyncio import CancelledError
|
||||||
|
from traceback import print_exception
|
||||||
|
import asyncio
|
||||||
|
from signal import Signals
|
||||||
|
from typing import Coroutine
|
||||||
|
|
||||||
|
from dexorder import configuration
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise Exception('this file is meant to be imported not executed')
|
||||||
|
|
||||||
|
|
||||||
|
ignorable_exceptions = [CancelledError]
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
async def _shutdown_coro(_sig, loop, extra_shutdown):
|
||||||
|
log.info('shutting down')
|
||||||
|
if extra_shutdown is not None:
|
||||||
|
extra_shutdown()
|
||||||
|
tasks = [t for t in asyncio.all_tasks() if t is not
|
||||||
|
asyncio.current_task()]
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
exceptions = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
loop.stop()
|
||||||
|
for x in exceptions:
|
||||||
|
if x is not None and x.__class__ not in ignorable_exceptions:
|
||||||
|
print_exception(x)
|
||||||
|
|
||||||
|
|
||||||
|
def execute(main:Coroutine, shutdown=None, parse_args=True):
|
||||||
|
if parse_args:
|
||||||
|
configuration.parse_args()
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
signals = Signals.SIGQUIT, Signals.SIGTERM, Signals.SIGINT
|
||||||
|
for s in signals:
|
||||||
|
loop.add_signal_handler(s, lambda sig=s: asyncio.create_task(_shutdown_coro(sig, loop, shutdown)))
|
||||||
|
task = loop.create_task(main)
|
||||||
|
loop.run_until_complete(task)
|
||||||
|
x = task.exception()
|
||||||
|
if x is not None:
|
||||||
|
print_exception(x)
|
||||||
|
for t in asyncio.all_tasks():
|
||||||
|
t.cancel()
|
||||||
|
else:
|
||||||
|
loop.run_forever()
|
||||||
|
loop.stop()
|
||||||
|
loop.close()
|
||||||
122
src/dexorder/bin/main.py
Normal file
122
src/dexorder/bin/main.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import logging
|
||||||
|
from asyncio import CancelledError
|
||||||
|
|
||||||
|
from hexbytes import HexBytes
|
||||||
|
from web3 import AsyncWeb3, WebsocketProviderV2, AsyncHTTPProvider
|
||||||
|
from web3.types import FilterParams
|
||||||
|
|
||||||
|
from dexorder import config, Blockchain
|
||||||
|
from dexorder.base.blockstate import BlockState, BlockDict
|
||||||
|
from dexorder.base.event_manager import EventManager
|
||||||
|
from dexorder.bin.executable import execute
|
||||||
|
from dexorder.configuration import resolve_rpc_url
|
||||||
|
from dexorder.db.model import Block
|
||||||
|
|
||||||
|
log = logging.getLogger('dexorder')
|
||||||
|
|
||||||
|
ROOT_AGE = 10 # todo set per chain
|
||||||
|
|
||||||
|
|
||||||
|
wallets = BlockDict('wallets')
|
||||||
|
|
||||||
|
def handle_transfer(event):
|
||||||
|
to_address = event.topics[2].hex()
|
||||||
|
wallets.add(to_address)
|
||||||
|
|
||||||
|
def setup_triggers(event_manager: EventManager):
|
||||||
|
event_manager.add_handler('Transfer(address,address,uint256)', handle_transfer)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""
|
||||||
|
1. load root stateBlockchain
|
||||||
|
a. if no root, init from head
|
||||||
|
b. if root is old, batch forward by height
|
||||||
|
2. discover new heads
|
||||||
|
2b. find in-memory ancestor else use root
|
||||||
|
3. context = ancestor->head diff
|
||||||
|
4. query global log filter
|
||||||
|
5. process new vaults
|
||||||
|
6. process new orders and cancels
|
||||||
|
a. new pools
|
||||||
|
7. process Swap events and generate pool prices
|
||||||
|
8. process price horizons
|
||||||
|
9. process token movement
|
||||||
|
10. process swap triggers (zero constraint tranches)
|
||||||
|
11. process price tranche triggers
|
||||||
|
12. process horizon tranche triggers
|
||||||
|
13. filter by time tranche triggers
|
||||||
|
14. bundle execution requests and send tx. tx has require(block<deadline)
|
||||||
|
15. on tx confirmation, the block height of all executed trigger requests is set to the tx block
|
||||||
|
"""
|
||||||
|
|
||||||
|
# db.connect()
|
||||||
|
# blockchain.connect()
|
||||||
|
ws_provider = WebsocketProviderV2(resolve_rpc_url(config.ws_url))
|
||||||
|
w3ws = AsyncWeb3.persistent_websocket(ws_provider)
|
||||||
|
http_provider = AsyncHTTPProvider(resolve_rpc_url(config.rpc_url))
|
||||||
|
w3 = AsyncWeb3(http_provider)
|
||||||
|
# w3.middleware_onion.remove('attrdict')
|
||||||
|
|
||||||
|
try:
|
||||||
|
chain_id = await w3ws.eth.chain_id
|
||||||
|
Blockchain.set_cur(Blockchain.for_id(chain_id))
|
||||||
|
|
||||||
|
event_manager = EventManager()
|
||||||
|
|
||||||
|
# todo load root
|
||||||
|
state = None
|
||||||
|
async with w3ws as w3ws:
|
||||||
|
await w3ws.eth.subscribe('newHeads')
|
||||||
|
while True:
|
||||||
|
async for head in w3ws.listen_to_websocket():
|
||||||
|
log.debug('head', head)
|
||||||
|
block_data = await w3.eth.get_block(head.hash.hex(), True)
|
||||||
|
block = Block(chain=chain_id,height=block_data.number,hash=block_data.hash,parent=block_data.parentHash,data=block_data)
|
||||||
|
block.set_latest(block)
|
||||||
|
block.set_cur(block)
|
||||||
|
if state is None:
|
||||||
|
state = BlockState(block,{})
|
||||||
|
BlockState.set_cur(state)
|
||||||
|
setup_triggers(event_manager)
|
||||||
|
log.info('Created new empty root state')
|
||||||
|
else:
|
||||||
|
ancestor = BlockState.cur().add_block(block)
|
||||||
|
if ancestor is None:
|
||||||
|
log.debug(f'discarded late-arriving head {block}')
|
||||||
|
elif type(ancestor) is int:
|
||||||
|
# todo backfill batches
|
||||||
|
log.error(f'backfill unimplemented for range {ancestor} to {block}')
|
||||||
|
else:
|
||||||
|
logs_filter = FilterParams(topics=list(event_manager.all_topics), blockhash=HexBytes(block.hash).hex())
|
||||||
|
log.debug(f'get logs {logs_filter}')
|
||||||
|
logs = await w3.eth.get_logs(logs_filter)
|
||||||
|
if logs:
|
||||||
|
log.debug('handle logs')
|
||||||
|
event_manager.handle_logs(logs)
|
||||||
|
# check for root promotion
|
||||||
|
if block.height - state.root_block.height > ROOT_AGE:
|
||||||
|
b = block
|
||||||
|
try:
|
||||||
|
for _ in range(1,ROOT_AGE):
|
||||||
|
# we walk backwards ROOT_AGE and promote what's there
|
||||||
|
b = state.by_hash[b.parent]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
log.debug(f'promoting root {b}')
|
||||||
|
state.promote_root(b)
|
||||||
|
log.debug('wallets: '+' '.join(k for k,_ in wallets.items()))
|
||||||
|
except CancelledError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if ws_provider.is_connected():
|
||||||
|
await ws_provider.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
log = logging.getLogger('dexorder')
|
||||||
|
log.setLevel(logging.DEBUG)
|
||||||
|
execute(main())
|
||||||
|
log.info('exiting')
|
||||||
4
src/dexorder/blockchain/__init__.py
Normal file
4
src/dexorder/blockchain/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from .old_dispatch import OldDispatcher
|
||||||
|
from .by_blockchain import ByBlockchainDict, ByBlockchainList, ByBlockchainCollection
|
||||||
|
from .connection import connect
|
||||||
|
from dexorder.base.chain import Ethereum, Polygon, Goerli, Mumbai, ArbitrumOne, BSC
|
||||||
55
src/dexorder/blockchain/by_blockchain.py
Normal file
55
src/dexorder/blockchain/by_blockchain.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
from typing import Generic, TypeVar, Any, Iterator
|
||||||
|
|
||||||
|
from dexorder import ctx, NARG
|
||||||
|
|
||||||
|
_T = TypeVar('_T')
|
||||||
|
|
||||||
|
|
||||||
|
class ByBlockchainSingleton:
|
||||||
|
def __init__(self, by_blockchain:dict[int,Any]):
|
||||||
|
self.by_blockchain = by_blockchain
|
||||||
|
|
||||||
|
def __getattr__(self, item):
|
||||||
|
return self.by_blockchain.__getattribute__(item)
|
||||||
|
|
||||||
|
|
||||||
|
class ByBlockchainCollection (Generic[_T]):
|
||||||
|
def __init__(self, by_blockchain:dict[int,dict[Any,_T]]=None):
|
||||||
|
self.by_blockchain = by_blockchain if by_blockchain is not None else {}
|
||||||
|
|
||||||
|
def __getitem__(self, item) -> _T:
|
||||||
|
return self.by_blockchain[ctx.chain_id][item]
|
||||||
|
|
||||||
|
|
||||||
|
class ByBlockchainDict (ByBlockchainCollection[_T], Generic[_T]):
|
||||||
|
|
||||||
|
def __getattr__(self, name: str) -> _T:
|
||||||
|
return self.by_blockchain[ctx.chain_id][name]
|
||||||
|
|
||||||
|
def get(self, item, default=None, *, chain_id=None) -> _T:
|
||||||
|
# will raise if default is NARG
|
||||||
|
if chain_id is None:
|
||||||
|
chain_id = ctx.chain_id
|
||||||
|
if chain_id is None:
|
||||||
|
raise KeyError('no ctx.chain_id set')
|
||||||
|
found = self.by_blockchain.get(chain_id, {}).get(item, default)
|
||||||
|
if found is NARG:
|
||||||
|
raise KeyError
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
class ByBlockchainList (ByBlockchainCollection[_T], Generic[_T]):
|
||||||
|
def __iter__(self) -> Iterator[_T]:
|
||||||
|
return iter(self.by_blockchain[ctx.chain_id])
|
||||||
|
|
||||||
|
def iter(self, *, chain_id=None) -> Iterator[_T]:
|
||||||
|
if chain_id is None:
|
||||||
|
chain_id = ctx.chain_id
|
||||||
|
return iter(self.by_blockchain[chain_id])
|
||||||
|
|
||||||
|
def get(self, index, *, chain_id=None) -> _T:
|
||||||
|
if chain_id is None:
|
||||||
|
chain_id = ctx.chain_id
|
||||||
|
if chain_id is None:
|
||||||
|
raise KeyError('no ctx.chain_id set')
|
||||||
|
return self.by_blockchain[chain_id][index]
|
||||||
2
src/dexorder/blockchain/chain_singletons.py
Normal file
2
src/dexorder/blockchain/chain_singletons.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
from dexorder import Blockchain
|
||||||
|
|
||||||
50
src/dexorder/blockchain/connection.py
Normal file
50
src/dexorder/blockchain/connection.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
from web3 import HTTPProvider, Web3
|
||||||
|
from web3.middleware import geth_poa_middleware, simple_cache_middleware
|
||||||
|
|
||||||
|
from dexorder import ctx
|
||||||
|
from dexorder.blockchain.util import get_contract_data
|
||||||
|
from ..configuration import resolve_rpc_url
|
||||||
|
|
||||||
|
|
||||||
|
def connect(rpc_url=None):
|
||||||
|
"""
|
||||||
|
connects to the rpc_url and configures the context
|
||||||
|
if you don't want to use ctx.account for this w3, either set ctx.account first or
|
||||||
|
use create_w3() and set w3.eth.default_account separately
|
||||||
|
"""
|
||||||
|
w3 = create_w3(rpc_url)
|
||||||
|
ctx.w3 = w3
|
||||||
|
return w3
|
||||||
|
|
||||||
|
|
||||||
|
def create_w3(rpc_url=None):
|
||||||
|
"""
|
||||||
|
this constructs a Web3 object but does NOT attach it to the context. consider using connect(...) instead
|
||||||
|
this does *not* attach any signer to the w3. make sure to inject the proper middleware with Account.attach(w3)
|
||||||
|
"""
|
||||||
|
# todo create a proxy w3 that rotates among rpc urls
|
||||||
|
# self.w3s = tuple(create_w3(url) for url in rpc_url_or_tag)
|
||||||
|
# chain_id = self.w3s[0].eth.chain_id
|
||||||
|
# assert all(w3.eth.chain_id == chain_id for w3 in self.w3s) # all rpc urls must be the same blockchain
|
||||||
|
# self.w3iter = itertools.cycle(self.w3s)
|
||||||
|
|
||||||
|
url = resolve_rpc_url(rpc_url)
|
||||||
|
w3 = Web3(HTTPProvider(url))
|
||||||
|
w3.middleware_onion.inject(geth_poa_middleware, layer=0)
|
||||||
|
w3.middleware_onion.add(simple_cache_middleware)
|
||||||
|
w3.eth.Contract = _make_contract(w3.eth)
|
||||||
|
return w3
|
||||||
|
|
||||||
|
|
||||||
|
def _make_contract(w3_eth):
|
||||||
|
def f(address, abi_or_name): # if abi, then it must already be in native object format, not a string
|
||||||
|
if type(abi_or_name) is str:
|
||||||
|
data = get_contract_data(abi_or_name)
|
||||||
|
abi = data['abi']
|
||||||
|
bytecode = data['bytecode']['object'] if address is None else None
|
||||||
|
else:
|
||||||
|
abi = abi_or_name
|
||||||
|
bytecode = None
|
||||||
|
return w3_eth.contract(address,abi=abi,bytecode=bytecode)
|
||||||
|
return f
|
||||||
|
|
||||||
7
src/dexorder/blockchain/util.py
Normal file
7
src/dexorder/blockchain/util.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
def get_contract_data(name):
|
||||||
|
with open(f'contract/out/{name}.sol/{name}.json') as file:
|
||||||
|
return json.load(file)
|
||||||
|
|
||||||
3
src/dexorder/configuration/__init__.py
Normal file
3
src/dexorder/configuration/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .standard_accounts import test_accounts
|
||||||
|
from .load import config, parse_args
|
||||||
|
from .resolve import resolve_rpc_url
|
||||||
94
src/dexorder/configuration/load.py
Normal file
94
src/dexorder/configuration/load.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import os
|
||||||
|
import tomllib
|
||||||
|
from tomllib import TOMLDecodeError
|
||||||
|
|
||||||
|
from omegaconf import OmegaConf, DictConfig
|
||||||
|
from omegaconf.errors import OmegaConfBaseException
|
||||||
|
|
||||||
|
from .schema import Config
|
||||||
|
from .standard_accounts import default_accounts_config
|
||||||
|
from .standard_tokens import default_token_config
|
||||||
|
|
||||||
|
schema = OmegaConf.structured(Config())
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigException (Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
|
||||||
|
# noinspection PyTypeChecker
|
||||||
|
result:ConfigDict = OmegaConf.merge(
|
||||||
|
schema,
|
||||||
|
load_tokens(),
|
||||||
|
load_accounts(),
|
||||||
|
from_toml('pool.toml'),
|
||||||
|
from_toml('dexorder.toml'),
|
||||||
|
from_toml('config.toml'),
|
||||||
|
from_toml('.secret.toml'),
|
||||||
|
from_env()
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def load_tokens():
|
||||||
|
token_conf = OmegaConf.create({'tokens': default_token_config})
|
||||||
|
try:
|
||||||
|
OmegaConf.merge(schema, token_conf)
|
||||||
|
return token_conf
|
||||||
|
except OmegaConfBaseException as _x:
|
||||||
|
raise ConfigException(f'Error while processing default tokens:\n{_x}')
|
||||||
|
|
||||||
|
|
||||||
|
def load_accounts():
|
||||||
|
accounts_conf = OmegaConf.create({'accounts': default_accounts_config})
|
||||||
|
try:
|
||||||
|
OmegaConf.merge(schema, accounts_conf)
|
||||||
|
return accounts_conf
|
||||||
|
except OmegaConfBaseException as _x:
|
||||||
|
raise ConfigException(f'Error while processing default accounts:\n{_x}')
|
||||||
|
|
||||||
|
|
||||||
|
def from_env(prefix='DEXORDER_'):
|
||||||
|
dotlist = []
|
||||||
|
for key, value in os.environ.items():
|
||||||
|
if key.startswith(prefix):
|
||||||
|
key = key[len(prefix):].lower().replace('__','.')
|
||||||
|
dotlist.append(key+'='+value)
|
||||||
|
result = OmegaConf.from_dotlist(dotlist)
|
||||||
|
try:
|
||||||
|
OmegaConf.merge(schema, result)
|
||||||
|
return result
|
||||||
|
except OmegaConfBaseException as x:
|
||||||
|
raise ConfigException(f'Error while parsing environment config:\n{x}')
|
||||||
|
|
||||||
|
|
||||||
|
def from_toml(filename):
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
with open(filename, 'rb') as file:
|
||||||
|
toml = tomllib.load(file)
|
||||||
|
result = OmegaConf.create(toml)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return OmegaConf.create()
|
||||||
|
OmegaConf.merge(schema, result)
|
||||||
|
return result
|
||||||
|
except (OmegaConfBaseException, TOMLDecodeError) as x:
|
||||||
|
raise ConfigException(f'Error while loading {filename}:\n{x}')
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(args=None):
|
||||||
|
""" should be called from binaries to parse args as command-line config settings """
|
||||||
|
# noinspection PyTypeChecker
|
||||||
|
try:
|
||||||
|
config.merge_with(OmegaConf.from_cli(args)) # updates config in-place. THANK YOU OmegaConf!
|
||||||
|
except OmegaConfBaseException as x:
|
||||||
|
raise ConfigException(f'Could not parse command-line args:\n{x}')
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigDict (Config, DictConfig): # give type hints from Config plus methods from DictConfig
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
13
src/dexorder/configuration/postprocess.py
Normal file
13
src/dexorder/configuration/postprocess.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from typing import Callable
|
||||||
|
from .schema import Config
|
||||||
|
|
||||||
|
|
||||||
|
_handlers = []
|
||||||
|
|
||||||
|
def add_config_handler(callback:Callable[[Config],None]):
|
||||||
|
_handlers.append(callback)
|
||||||
|
|
||||||
|
def postprocess_config(conf:Config):
|
||||||
|
for callback in _handlers:
|
||||||
|
callback(conf)
|
||||||
|
|
||||||
13
src/dexorder/configuration/resolve.py
Normal file
13
src/dexorder/configuration/resolve.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from .load import config
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_rpc_url(rpc_url=None):
|
||||||
|
if rpc_url is None:
|
||||||
|
rpc_url = config.rpc_url
|
||||||
|
if rpc_url == 'test':
|
||||||
|
return 'http://localhost:8545'
|
||||||
|
try:
|
||||||
|
return config.rpc_urls[rpc_url] # look up aliases
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
return rpc_url
|
||||||
73
src/dexorder/configuration/schema.py
Normal file
73
src/dexorder/configuration/schema.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional, Union
|
||||||
|
|
||||||
|
|
||||||
|
# SCHEMA NOTES:
|
||||||
|
# - avoid using int keys since (1) they are hard to decipher by a human and (2) the Python TOML parser mistypes int keys
|
||||||
|
# as strings in certain situations
|
||||||
|
# - do not nest structured types more than one level deep. it confuses the config's typing system
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Config:
|
||||||
|
db_url: str = 'postgresql://dexorder:redroxed@localhost/dexorder'
|
||||||
|
dump_sql: bool = False
|
||||||
|
chain: Union[int,str] = 'Arbitrum'
|
||||||
|
|
||||||
|
# rpc_url may also reference the aliases from the foundry.toml's rpc_endpoints section
|
||||||
|
rpc_url: str = 'http://localhost:8545'
|
||||||
|
ws_url: str = 'ws://localhost:8545'
|
||||||
|
rpc_urls: Optional[dict[str,str]] = field(default_factory=dict)
|
||||||
|
|
||||||
|
account: Optional[str] = None # may be a private key or an account alias
|
||||||
|
accounts: Optional[dict[str,str]] = field(default_factory=dict) # account aliases
|
||||||
|
min_gas: str = '0'
|
||||||
|
|
||||||
|
tokens: list['TokenConfig'] = field(default_factory=list)
|
||||||
|
dexorders: list['DexorderConfig'] = field(default_factory=list)
|
||||||
|
pools: list['PoolConfig'] = field(default_factory=list)
|
||||||
|
query_helpers: dict[str,str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
# Dispatcher
|
||||||
|
polling_interval: float = 0.2
|
||||||
|
backoff_factor: float = 1.5
|
||||||
|
max_interval: float = 10
|
||||||
|
|
||||||
|
# positive numbers are absolute block numbers and negative numbers are relative to the latest block
|
||||||
|
backfill: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TokenConfig:
|
||||||
|
name: str
|
||||||
|
symbol: str
|
||||||
|
decimals: int
|
||||||
|
chain: str
|
||||||
|
address: str
|
||||||
|
abi: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PoolConfig:
|
||||||
|
chain: str
|
||||||
|
address: str
|
||||||
|
token_a: str
|
||||||
|
token_b: str
|
||||||
|
fee: int
|
||||||
|
enabled: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DexorderConfig:
|
||||||
|
chain: str
|
||||||
|
address: str
|
||||||
|
pool: str
|
||||||
|
owner: str
|
||||||
|
name: Optional[str] = None
|
||||||
|
width: Optional[int] = None # in bps aka ticks
|
||||||
|
width_above: Optional[int] = None # defaults to width
|
||||||
|
width_below: Optional[int] = None # defaults to width
|
||||||
|
offset: Optional[int] = None # in bps aka ticks
|
||||||
|
offset_above: Optional[int] = None # defaults to offset
|
||||||
|
offset_below: Optional[int] = None # defaults to offset
|
||||||
|
ema: Optional[int] = None
|
||||||
|
|
||||||
18
src/dexorder/configuration/standard_accounts.py
Normal file
18
src/dexorder/configuration/standard_accounts.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
test_accounts = {
|
||||||
|
'test0': '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80',
|
||||||
|
'test1': '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d',
|
||||||
|
'test2': '0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a',
|
||||||
|
'test3': '0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6',
|
||||||
|
'test4': '0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a',
|
||||||
|
'test5': '0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba',
|
||||||
|
'test6': '0x92db14e403b83dfe3df233f83dfa3a0d7096f21ca9b0d6d6b8d88b2b4ec1564e',
|
||||||
|
'test7': '0x4bbbf85ce3377467afe5d46f804f221813b2bb87f24d81f60f1fcdbf7cbf4356',
|
||||||
|
'test8': '0xdbda1821b80551c9d65939329250298aa3472ba22feea921c0cf5d620ea67b97',
|
||||||
|
'test9': '0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6',
|
||||||
|
}
|
||||||
|
|
||||||
|
default_accounts_config = {}
|
||||||
|
|
||||||
|
default_accounts_config.update(test_accounts)
|
||||||
|
|
||||||
|
default_accounts_config['test'] = default_accounts_config['test0']
|
||||||
8
src/dexorder/configuration/standard_tokens.py
Normal file
8
src/dexorder/configuration/standard_tokens.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from .schema import TokenConfig
|
||||||
|
|
||||||
|
default_token_config = [
|
||||||
|
# TokenConfig('Wrapped Matic', 'WMATIC', 18, 'Polygon', '0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270', abi='WMATIC'),
|
||||||
|
# TokenConfig('Wrapped Ethereum','WETH', 18, 'Polygon', '0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619'),
|
||||||
|
# TokenConfig('Wrapped Bitcoin', 'WBTC', 8, 'Polygon', '0x1BFD67037B42Cf73acF2047067bd4F2C47D9BfD6'),
|
||||||
|
# TokenConfig('USD Coin', 'USDC', 6, 'Polygon', '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174'),
|
||||||
|
]
|
||||||
25
src/dexorder/db/__init__.py
Normal file
25
src/dexorder/db/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import sqlalchemy
|
||||||
|
|
||||||
|
from .migrate import migrate_database
|
||||||
|
from .. import config, ctx
|
||||||
|
|
||||||
|
|
||||||
|
# noinspection PyShadowingNames
|
||||||
|
def connect(url=None, migrate=True, reconnect=False, dump_sql=None):
|
||||||
|
if ctx.engine is not None and not reconnect:
|
||||||
|
return
|
||||||
|
if url is None:
|
||||||
|
url = config.db_url
|
||||||
|
if dump_sql is None:
|
||||||
|
dump_sql = config.dump_sql
|
||||||
|
engine = sqlalchemy.create_engine(url, echo=dump_sql)
|
||||||
|
if migrate:
|
||||||
|
migrate_database()
|
||||||
|
with engine.connect() as connection:
|
||||||
|
connection.execute(sqlalchemy.text("SET TIME ZONE 'UTC'"))
|
||||||
|
result = connection.execute(sqlalchemy.text("select version_num from alembic_version"))
|
||||||
|
for row in result:
|
||||||
|
print(f'database revision {row[0]}')
|
||||||
|
ctx.engine = engine
|
||||||
|
return
|
||||||
|
raise Exception('database version not found')
|
||||||
87
src/dexorder/db/column.py
Normal file
87
src/dexorder/db/column.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
from sqlalchemy import SMALLINT, INTEGER, BIGINT
|
||||||
|
from sqlalchemy.orm import mapped_column
|
||||||
|
from typing_extensions import Annotated
|
||||||
|
|
||||||
|
from dexorder import Fixed2, Blockchain as NativeBlockchain
|
||||||
|
from . import column_types as t
|
||||||
|
|
||||||
|
# noinspection DuplicatedCode
|
||||||
|
Uint8 = Annotated[int, mapped_column(SMALLINT)]
|
||||||
|
Uint16 = Annotated[int, mapped_column(SMALLINT)]
|
||||||
|
Uint24 = Annotated[int, mapped_column(INTEGER)]
|
||||||
|
Uint32 = Annotated[int, mapped_column(INTEGER)]
|
||||||
|
Uint40 = Annotated[int, mapped_column(BIGINT)]
|
||||||
|
Uint48 = Annotated[int, mapped_column(BIGINT)]
|
||||||
|
Uint56 = Annotated[int, mapped_column(BIGINT)]
|
||||||
|
Uint64 = Annotated[int, mapped_column(t.IntBits(64, False))] # cannot use BIGINT since an unsigned value could overflow it
|
||||||
|
Uint72 = Annotated[int, mapped_column(t.IntBits(72, False))]
|
||||||
|
Uint80 = Annotated[int, mapped_column(t.IntBits(80, False))]
|
||||||
|
Uint88 = Annotated[int, mapped_column(t.IntBits(88, False))]
|
||||||
|
Uint96 = Annotated[int, mapped_column(t.IntBits(96, False))]
|
||||||
|
Uint104 = Annotated[int, mapped_column(t.IntBits(104, False))]
|
||||||
|
Uint112 = Annotated[int, mapped_column(t.IntBits(112, False))]
|
||||||
|
Uint120 = Annotated[int, mapped_column(t.IntBits(120, False))]
|
||||||
|
Uint128 = Annotated[int, mapped_column(t.IntBits(128, False))]
|
||||||
|
Uint136 = Annotated[int, mapped_column(t.IntBits(136, False))]
|
||||||
|
Uint144 = Annotated[int, mapped_column(t.IntBits(144, False))]
|
||||||
|
Uint152 = Annotated[int, mapped_column(t.IntBits(152, False))]
|
||||||
|
Uint160 = Annotated[int, mapped_column(t.IntBits(160, False))]
|
||||||
|
Uint168 = Annotated[int, mapped_column(t.IntBits(168, False))]
|
||||||
|
Uint176 = Annotated[int, mapped_column(t.IntBits(176, False))]
|
||||||
|
Uint184 = Annotated[int, mapped_column(t.IntBits(184, False))]
|
||||||
|
Uint192 = Annotated[int, mapped_column(t.IntBits(192, False))]
|
||||||
|
Uint200 = Annotated[int, mapped_column(t.IntBits(200, False))]
|
||||||
|
Uint208 = Annotated[int, mapped_column(t.IntBits(208, False))]
|
||||||
|
Uint216 = Annotated[int, mapped_column(t.IntBits(216, False))]
|
||||||
|
Uint224 = Annotated[int, mapped_column(t.IntBits(224, False))]
|
||||||
|
Uint232 = Annotated[int, mapped_column(t.IntBits(232, False))]
|
||||||
|
Uint240 = Annotated[int, mapped_column(t.IntBits(240, False))]
|
||||||
|
Uint248 = Annotated[int, mapped_column(t.IntBits(248, False))]
|
||||||
|
Uint256 = Annotated[int, mapped_column(t.IntBits(256, False))]
|
||||||
|
|
||||||
|
# noinspection DuplicatedCode
|
||||||
|
Int8 = Annotated[int, mapped_column(SMALLINT)]
|
||||||
|
Int16 = Annotated[int, mapped_column(SMALLINT)]
|
||||||
|
Int24 = Annotated[int, mapped_column(INTEGER)]
|
||||||
|
Int32 = Annotated[int, mapped_column(INTEGER)]
|
||||||
|
Int40 = Annotated[int, mapped_column(BIGINT)]
|
||||||
|
Int48 = Annotated[int, mapped_column(BIGINT)]
|
||||||
|
Int56 = Annotated[int, mapped_column(BIGINT)]
|
||||||
|
Int64 = Annotated[int, mapped_column(BIGINT)]
|
||||||
|
Int72 = Annotated[int, mapped_column(t.IntBits(72, True))]
|
||||||
|
Int80 = Annotated[int, mapped_column(t.IntBits(80, True))]
|
||||||
|
Int88 = Annotated[int, mapped_column(t.IntBits(88, True))]
|
||||||
|
Int96 = Annotated[int, mapped_column(t.IntBits(96, True))]
|
||||||
|
Int104 = Annotated[int, mapped_column(t.IntBits(104, True))]
|
||||||
|
Int112 = Annotated[int, mapped_column(t.IntBits(112, True))]
|
||||||
|
Int120 = Annotated[int, mapped_column(t.IntBits(120, True))]
|
||||||
|
Int128 = Annotated[int, mapped_column(t.IntBits(128, True))]
|
||||||
|
Int136 = Annotated[int, mapped_column(t.IntBits(136, True))]
|
||||||
|
Int144 = Annotated[int, mapped_column(t.IntBits(144, True))]
|
||||||
|
Int152 = Annotated[int, mapped_column(t.IntBits(152, True))]
|
||||||
|
Int160 = Annotated[int, mapped_column(t.IntBits(160, True))]
|
||||||
|
Int168 = Annotated[int, mapped_column(t.IntBits(168, True))]
|
||||||
|
Int176 = Annotated[int, mapped_column(t.IntBits(176, True))]
|
||||||
|
Int184 = Annotated[int, mapped_column(t.IntBits(184, True))]
|
||||||
|
Int192 = Annotated[int, mapped_column(t.IntBits(192, True))]
|
||||||
|
Int200 = Annotated[int, mapped_column(t.IntBits(200, True))]
|
||||||
|
Int208 = Annotated[int, mapped_column(t.IntBits(208, True))]
|
||||||
|
Int216 = Annotated[int, mapped_column(t.IntBits(216, True))]
|
||||||
|
Int224 = Annotated[int, mapped_column(t.IntBits(224, True))]
|
||||||
|
Int232 = Annotated[int, mapped_column(t.IntBits(232, True))]
|
||||||
|
Int240 = Annotated[int, mapped_column(t.IntBits(240, True))]
|
||||||
|
Int248 = Annotated[int, mapped_column(t.IntBits(248, True))]
|
||||||
|
Int256 = Annotated[int, mapped_column(t.IntBits(256, True))]
|
||||||
|
|
||||||
|
Address = Annotated[str, mapped_column(t.Address())]
|
||||||
|
|
||||||
|
BlockCol = Annotated[int, mapped_column(BIGINT)]
|
||||||
|
|
||||||
|
Blockchain = Annotated[NativeBlockchain, mapped_column(t.Blockchain)]
|
||||||
|
|
||||||
|
# Uniswap aliases
|
||||||
|
Tick = Int24
|
||||||
|
SqrtPriceX96 = Uint160
|
||||||
|
Liquidity = Uint128
|
||||||
|
Q128X96 = Annotated[Fixed2, mapped_column(t.Fixed(128, 96))]
|
||||||
|
Q256X128 = Annotated[Fixed2, mapped_column(t.Fixed(256, 128))]
|
||||||
71
src/dexorder/db/column_types.py
Normal file
71
src/dexorder/db/column_types.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import math
|
||||||
|
|
||||||
|
from sqlalchemy import TypeDecorator, BIGINT
|
||||||
|
from sqlalchemy.dialects.postgresql import BYTEA
|
||||||
|
from web3 import Web3
|
||||||
|
|
||||||
|
from dexorder import Fixed2 as NativeFixed, Blockchain as NativeBlockchain
|
||||||
|
|
||||||
|
|
||||||
|
class Address(TypeDecorator):
|
||||||
|
impl = BYTEA
|
||||||
|
cache_ok = True
|
||||||
|
|
||||||
|
def process_bind_param(self, value, dialect):
|
||||||
|
return bytes.fromhex(value[2:] if value.startswith('0x') else value)
|
||||||
|
|
||||||
|
def process_result_value(self, value: bytes, dialect):
|
||||||
|
return Web3.to_checksum_address(value.hex())
|
||||||
|
|
||||||
|
|
||||||
|
class Blockchain(TypeDecorator):
|
||||||
|
impl = BIGINT
|
||||||
|
cache_ok = True
|
||||||
|
|
||||||
|
def process_bind_param(self, value: NativeBlockchain, dialect):
|
||||||
|
return value.chain_id
|
||||||
|
|
||||||
|
def process_result_value(self, value: int, dialect):
|
||||||
|
return Blockchain.for_id(value)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Alembic instantiates these custom column types by calling their __init__ function as if they were the underlying
|
||||||
|
# type. Therefore, we cannot change the init function signature, so we use wrapper methods to set member variables
|
||||||
|
# after instance creation. This way, Alembic calls __init__ with the params for the underlying type, but at runtime
|
||||||
|
# when we construct our custom column types, they have the extra members set by the wrapper function.
|
||||||
|
|
||||||
|
class _IntBits(TypeDecorator):
|
||||||
|
impl = BYTEA
|
||||||
|
cache_ok = True
|
||||||
|
|
||||||
|
def process_bind_param(self, value, dialect):
|
||||||
|
assert( not self.signed or value >= 0)
|
||||||
|
return int(value).to_bytes(self.length, 'big', signed=self.signed)
|
||||||
|
|
||||||
|
def process_result_value(self, value, dialect):
|
||||||
|
return int.from_bytes(value, 'big', signed=self.signed)
|
||||||
|
|
||||||
|
|
||||||
|
def IntBits(bits, signed):
|
||||||
|
result = _IntBits(math.ceil(bits/8))
|
||||||
|
result.bits = bits
|
||||||
|
result.signed = signed
|
||||||
|
return result
|
||||||
|
|
||||||
|
class _Fixed(TypeDecorator):
|
||||||
|
impl = BYTEA
|
||||||
|
cache_ok = True
|
||||||
|
|
||||||
|
def process_bind_param(self, value, dialect):
|
||||||
|
assert( not self.signed or value >= 0)
|
||||||
|
return int(value).to_bytes(self.length, 'big', signed=self.signed)
|
||||||
|
def process_result_value(self, value, dialect):
|
||||||
|
return NativeFixed(int.from_bytes(value, 'big', signed=self.signed), self.dbits)
|
||||||
|
|
||||||
|
def Fixed(bits, dbits, signed=False):
|
||||||
|
result = _Fixed(math.ceil(bits/8))
|
||||||
|
result.bits = bits
|
||||||
|
result.dbits = dbits
|
||||||
|
result.signed = signed
|
||||||
|
return result
|
||||||
10
src/dexorder/db/migrate.py
Normal file
10
src/dexorder/db/migrate.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_database():
|
||||||
|
completed = subprocess.run('alembic upgrade head', shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
||||||
|
if completed.returncode != 0:
|
||||||
|
print(completed.stdout.decode(), file=sys.stderr)
|
||||||
|
print('FATAL: database migration failed!', file=sys.stderr)
|
||||||
|
exit(1)
|
||||||
2
src/dexorder/db/model/__init__.py
Normal file
2
src/dexorder/db/model/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
from .base import Base
|
||||||
|
from .block import Block
|
||||||
16
src/dexorder/db/model/base.py
Normal file
16
src/dexorder/db/model/base.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from sqlalchemy.orm import DeclarativeBase, declared_attr
|
||||||
|
|
||||||
|
from dexorder import ctx
|
||||||
|
|
||||||
|
|
||||||
|
# add Base as the -last- class inherited on classes which should get tables
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
# noinspection PyMethodParameters
|
||||||
|
@declared_attr.directive
|
||||||
|
def __tablename__(cls) -> str:
|
||||||
|
return cls.__name__.lower()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get(cls, **kwargs):
|
||||||
|
return ctx.session.get(cls, kwargs)
|
||||||
|
|
||||||
37
src/dexorder/db/model/block.py
Normal file
37
src/dexorder/db/model/block.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from contextvars import ContextVar
|
||||||
|
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from dexorder.db.model import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Block(Base):
|
||||||
|
chain: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
height: Mapped[int] = mapped_column(primary_key=True) # timescaledb index
|
||||||
|
hash: Mapped[bytes] = mapped_column(primary_key=True)
|
||||||
|
parent: Mapped[bytes]
|
||||||
|
data: Mapped[dict] = mapped_column(JSONB)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.height}_{self.hash.hex()}'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def cur() -> 'Block':
|
||||||
|
return _cur.get()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def set_cur(value: 'Block'):
|
||||||
|
_cur.set(value)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def latest() -> 'Block':
|
||||||
|
return _latest.get()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def set_latest(value: 'Block'):
|
||||||
|
_latest.set(value)
|
||||||
|
|
||||||
|
|
||||||
|
_cur = ContextVar[Block]('Block.cur')
|
||||||
|
_latest = ContextVar[Block]('Block.latest')
|
||||||
14
src/dexorder/util/__init__.py
Normal file
14
src/dexorder/util/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
from .async_yield import async_yield
|
||||||
|
from .tick_math import nearest_available_ticks, round_tick, spans_tick, spans_range
|
||||||
|
|
||||||
|
|
||||||
|
def align_decimal(value, left_columns) -> str:
|
||||||
|
"""
|
||||||
|
returns a string where the decimal point in value is aligned to have left_columns of characters before it
|
||||||
|
"""
|
||||||
|
s = str(value)
|
||||||
|
pad = max(left_columns - len(re.sub(r'[^0-9]*$','',s.split('.')[0])), 0)
|
||||||
|
return ' ' * pad + s
|
||||||
|
|
||||||
5
src/dexorder/util/async_yield.py
Normal file
5
src/dexorder/util/async_yield.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def async_yield():
|
||||||
|
# a value of exactly 0 doesn't seem to work as well, so we set 1 nanosecond
|
||||||
|
await asyncio.sleep(1e-9)
|
||||||
20
src/dexorder/util/convert.py
Normal file
20
src/dexorder/util/convert.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import math
|
||||||
|
|
||||||
|
|
||||||
|
def price_to_tick(p):
|
||||||
|
return round(math.log(p)/math.log(1.0001))
|
||||||
|
|
||||||
|
def tick_to_price(t):
|
||||||
|
return math.pow(1.0001,t)
|
||||||
|
|
||||||
|
def tick_to_sqrt_price(t):
|
||||||
|
return math.pow(1.0001**.5,t)
|
||||||
|
|
||||||
|
def to_fixed(value, decimals):
|
||||||
|
return round(value * 10**decimals)
|
||||||
|
|
||||||
|
def from_fixed(value, decimals):
|
||||||
|
return value / 10**decimals
|
||||||
|
|
||||||
|
def tick_to_sqrtPriceX96(tick):
|
||||||
|
return round(math.sqrt(tick_to_price(tick)) * 2**96)
|
||||||
9
src/dexorder/util/cwd.py
Normal file
9
src/dexorder/util/cwd.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def _cwd():
|
||||||
|
while 'alembic.ini' not in os.listdir():
|
||||||
|
if os.getcwd() == '/':
|
||||||
|
print('FATAL: could not find project root directory')
|
||||||
|
exit(1)
|
||||||
|
os.chdir(os.path.normpath('..'))
|
||||||
16
src/dexorder/util/json.py
Normal file
16
src/dexorder/util/json.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from hexbytes import HexBytes
|
||||||
|
from orjson import orjson
|
||||||
|
from web3.datastructures import ReadableAttributeDict
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize(v):
|
||||||
|
# todo wrap json.dumps()
|
||||||
|
if isinstance(v,HexBytes):
|
||||||
|
return v.hex()
|
||||||
|
if isinstance(v,ReadableAttributeDict):
|
||||||
|
return v.__dict__
|
||||||
|
raise ValueError(v)
|
||||||
|
|
||||||
|
|
||||||
|
def dumps(obj):
|
||||||
|
return orjson.dumps(obj, default=_serialize)
|
||||||
9
src/dexorder/util/shutdown.py
Normal file
9
src/dexorder/util/shutdown.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
log = logging.getLogger('dexorder')
|
||||||
|
|
||||||
|
def fatal(message, exception=None):
|
||||||
|
if exception is None and isinstance(message, (BaseException,RuntimeError)):
|
||||||
|
exception = message
|
||||||
|
log.exception(message, exc_info=exception)
|
||||||
|
exit(1)
|
||||||
14
src/dexorder/util/sql.py
Normal file
14
src/dexorder/util/sql.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
|
|
||||||
|
def where_time_range(sql, time_column, start: Union[datetime,timedelta,None] = None, end: Union[datetime,timedelta,None] = None):
|
||||||
|
if start is not None:
|
||||||
|
if isinstance(start, timedelta):
|
||||||
|
start = datetime.now() - abs(start)
|
||||||
|
sql = sql.where(time_column >= start)
|
||||||
|
if end is not None:
|
||||||
|
if isinstance(end, timedelta):
|
||||||
|
end = datetime.now() - abs(end)
|
||||||
|
sql = sql.where(time_column < end)
|
||||||
|
return sql
|
||||||
24
src/dexorder/util/tick_math.py
Normal file
24
src/dexorder/util/tick_math.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
|
||||||
|
def round_tick(tick, tick_spacing):
|
||||||
|
"""
|
||||||
|
returns the nearest available tick
|
||||||
|
"""
|
||||||
|
return round(tick/tick_spacing) * tick_spacing
|
||||||
|
|
||||||
|
|
||||||
|
def nearest_available_ticks(tick, tick_spacing):
|
||||||
|
"""
|
||||||
|
returns the two available ticks just below and above the given tick
|
||||||
|
"""
|
||||||
|
lower = tick // tick_spacing * tick_spacing
|
||||||
|
upper = lower + tick_spacing
|
||||||
|
return lower, upper
|
||||||
|
|
||||||
|
|
||||||
|
def spans_tick(tick, lower, upper):
|
||||||
|
return spans_range( *nearest_available_ticks(tick), lower, upper)
|
||||||
|
|
||||||
|
|
||||||
|
def spans_range(below, above, lower, upper):
|
||||||
|
return lower < above and upper > below
|
||||||
|
|
||||||
62
test/test_blockstate.py
Normal file
62
test/test_blockstate.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
from dexorder.base.blockstate import BlockState, BlockDict
|
||||||
|
from dexorder.db.model.block import Block
|
||||||
|
|
||||||
|
block_10 = Block(chain=1, height=10, hash=bytes.fromhex('10'), parent=bytes.fromhex('09'), data=None)
|
||||||
|
block_11a = Block(chain=1, height=11, hash=bytes.fromhex('1a'), parent=block_10.hash, data=None)
|
||||||
|
block_11b = Block(chain=1, height=11, hash=bytes.fromhex('1b'), parent=block_10.hash, data=None)
|
||||||
|
block_12a = Block(chain=1, height=12, hash=bytes.fromhex('12'), parent=block_11a.hash, data=None)
|
||||||
|
state = BlockState(block_10, {'series':{'foo':'bar'}})
|
||||||
|
BlockState.set_cur(state)
|
||||||
|
d = BlockDict('series')
|
||||||
|
|
||||||
|
def start_block(b):
|
||||||
|
Block.set_cur(b)
|
||||||
|
state.add_block(b)
|
||||||
|
|
||||||
|
start_block(block_11a)
|
||||||
|
del d['foo']
|
||||||
|
d['foue'] = 'barre'
|
||||||
|
|
||||||
|
start_block(block_12a)
|
||||||
|
d['foo'] = 'bar2'
|
||||||
|
|
||||||
|
start_block(block_11b)
|
||||||
|
d['fu'] = 'ku'
|
||||||
|
|
||||||
|
def print_dict(x:dict=d):
|
||||||
|
for k, v in x.items():
|
||||||
|
print(f'{k:>10} : {v}')
|
||||||
|
|
||||||
|
for block in [block_10,block_11a,block_12a,block_11b]:
|
||||||
|
Block.set_cur(block)
|
||||||
|
print()
|
||||||
|
print(Block.cur().hash)
|
||||||
|
print_dict()
|
||||||
|
|
||||||
|
def test11b():
|
||||||
|
Block.set_cur(block_11b)
|
||||||
|
assert 'fu' in d
|
||||||
|
assert d['fu'] == 'ku'
|
||||||
|
assert 'foo' in d
|
||||||
|
assert d['foo'] == 'bar'
|
||||||
|
|
||||||
|
def test12a():
|
||||||
|
Block.set_cur(block_12a)
|
||||||
|
assert 'fu' not in d
|
||||||
|
assert 'foo' in d
|
||||||
|
assert d['foo'] == 'bar2'
|
||||||
|
assert 'foue' in d
|
||||||
|
assert d['foue'] == 'barre'
|
||||||
|
|
||||||
|
test11b()
|
||||||
|
test12a()
|
||||||
|
state.promote_root(block_11a)
|
||||||
|
print()
|
||||||
|
print('promoted root')
|
||||||
|
print_dict(state.root_state)
|
||||||
|
test12a()
|
||||||
|
state.promote_root(block_12a)
|
||||||
|
print()
|
||||||
|
print('promoted root')
|
||||||
|
print_dict(state.root_state)
|
||||||
|
test12a()
|
||||||
Reference in New Issue
Block a user