From 8078a0536fed9de515e82114f49bf9431bc0e145 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Fri, 8 Aug 2025 13:36:25 +0200 Subject: [PATCH 001/101] fix: restore TxResult.tx_hash --- derive_client/data_types/models.py | 2 +- derive_client/utils/w3.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/derive_client/data_types/models.py b/derive_client/data_types/models.py index 288d33e0..2919dbdd 100644 --- a/derive_client/data_types/models.py +++ b/derive_client/data_types/models.py @@ -170,7 +170,7 @@ def target_chain(self) -> ChainID: @dataclass(config=ConfigDict(validate_assignment=True)) class TxResult: - tx_hash: TxHash | None = None + tx_hash: TxHash tx_receipt: PAttributeDict | None = None exception: PException | None = None diff --git a/derive_client/utils/w3.py b/derive_client/utils/w3.py index b55c872d..1239f04d 100644 --- a/derive_client/utils/w3.py +++ b/derive_client/utils/w3.py @@ -257,7 +257,7 @@ def send_and_confirm_tx( except Exception as send_err: msg = f"❌ Failed to send tx for {action}, error: {send_err!r}" logger.error(msg) - return TxResult(exception=send_err, tx_hash=None, tx_receipt=None) + raise TxSubmissionError(msg) from send_err try: tx_receipt = wait_for_tx_receipt(w3=w3, tx_hash=tx_hash) From 87838c7b0d8784bb2b437e75b6f656bdeefccc4f Mon Sep 17 00:00:00 2001 From: zarathustra Date: Fri, 8 Aug 2025 13:43:36 +0200 Subject: [PATCH 002/101] feat: bump minimal required python version to 3.10 --- poetry.lock | 195 ++++++++++++++++++++++++++++++++----------------- pyproject.toml | 2 +- 2 files changed, 130 insertions(+), 67 deletions(-) diff --git a/poetry.lock b/poetry.lock index 14121489..26d0f36e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -6,6 +6,7 @@ version = "2.6.1" description = "Happy Eyeballs for asyncio" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8"}, {file = "aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558"}, @@ -17,6 +18,7 @@ version = "3.12.13" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "aiohttp-3.12.13-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5421af8f22a98f640261ee48aae3a37f0c41371e99412d55eaf2f8a46d5dad29"}, {file = "aiohttp-3.12.13-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fcda86f6cb318ba36ed8f1396a6a4a3fd8f856f84d426584392083d10da4de0"}, @@ -117,7 +119,7 @@ propcache = ">=0.2.0" yarl = ">=1.17.0,<2.0" [package.extras] -speedups = ["Brotli", "aiodns (>=3.3.0)", "brotlicffi"] +speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns (>=3.3.0)", "brotlicffi ; platform_python_implementation != \"CPython\""] [[package]] name = "aiosignal" @@ -125,6 +127,7 @@ version = "1.3.2" description = "aiosignal: a list of registered asynchronous callbacks" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5"}, {file = "aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54"}, @@ -139,6 +142,7 @@ version = "0.7.0" description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, @@ -150,6 +154,8 @@ version = "5.0.1" description = "Timeout context manager for asyncio programs" optional = false python-versions = ">=3.8" +groups = ["main"] +markers = "python_version < \"3.11\"" files = [ {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, @@ -161,18 +167,19 @@ version = "25.3.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3"}, {file = "attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b"}, ] [package.extras] -benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier"] -tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] +tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] [[package]] name = "bitarray" @@ -180,6 +187,7 @@ version = "3.4.3" description = "efficient arrays of booleans -- C extension" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "bitarray-3.4.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a0c126a6ed1d3cd68cd91c0056cee8edcf6aa57c557b555528fe37375e72ea74"}, {file = "bitarray-3.4.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:690fc6d2b5c5e267f643e3720e8b4203838d3f30439e2070dccfae473b8223c3"}, @@ -323,6 +331,7 @@ version = "24.10.0" description = "The uncompromising code formatter." optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "black-24.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812"}, {file = "black-24.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea"}, @@ -369,6 +378,7 @@ version = "2025.6.15" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" +groups = ["main", "dev"] files = [ {file = "certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057"}, {file = "certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b"}, @@ -380,6 +390,7 @@ version = "3.4.2" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" +groups = ["main", "dev"] files = [ {file = "charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941"}, {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd"}, @@ -481,6 +492,7 @@ version = "2.1.1" description = "Python bindings for C-KZG-4844" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "ckzg-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b9825a1458219e8b4b023012b8ef027ef1f47e903f9541cbca4615f80132730"}, {file = "ckzg-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e2a40a3ba65cca4b52825d26829e6f7eb464aa27a9e9efb6b8b2ce183442c741"}, @@ -590,6 +602,7 @@ version = "0.19.0" description = "Build Nice User Interfaces In The Terminal" optional = false python-versions = "<4.0,>=3.9" +groups = ["dev"] files = [ {file = "cli_ui-0.19.0-py3-none-any.whl", hash = "sha256:1cf1b93328f7377730db29507e10bcb29ccc1427ceef45714b522d1f2055e7cd"}, {file = "cli_ui-0.19.0.tar.gz", hash = "sha256:59cdab0c6a2a6703c61b31cb75a1943076888907f015fffe15c5a8eb41a933aa"}, @@ -606,6 +619,7 @@ version = "8.1.8" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" +groups = ["main", "dev"] files = [ {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, @@ -620,10 +634,12 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {main = "platform_system == \"Windows\""} [[package]] name = "cytoolz" @@ -631,6 +647,8 @@ version = "1.0.1" description = "Cython implementation of Toolz: High performance functional utilities" optional = false python-versions = ">=3.8" +groups = ["main"] +markers = "implementation_name == \"cpython\"" files = [ {file = "cytoolz-1.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cec9af61f71fc3853eb5dca3d42eb07d1f48a4599fa502cbe92adde85f74b042"}, {file = "cytoolz-1.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:140bbd649dbda01e91add7642149a5987a7c3ccc251f2263de894b89f50b6608"}, @@ -746,6 +764,7 @@ version = "0.0.11" description = "Python package to sign on-chain self-custodial requests for orders, transfers, deposits, withdrawals and RFQs." optional = false python-versions = "<4.0,>=3.9" +groups = ["main"] files = [ {file = "derive_action_signing-0.0.11-py3-none-any.whl", hash = "sha256:3b8c3ee7b3928ef874a9ac57cae6f7e376eeda56312c8ae0b043d47f35213c84"}, {file = "derive_action_signing-0.0.11.tar.gz", hash = "sha256:96e49a4a27e0357b0803843a9c9a42e19c9c582c3db5ac0a23621d7df93784c9"}, @@ -765,6 +784,7 @@ version = "0.6.2" description = "Pythonic argument parser, that will make you smile" optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, ] @@ -775,6 +795,7 @@ version = "5.2.0" description = "eth_abi: Python utilities for working with Ethereum ABI definitions, especially encoding and decoding" optional = false python-versions = "<4,>=3.8" +groups = ["main"] files = [ {file = "eth_abi-5.2.0-py3-none-any.whl", hash = "sha256:17abe47560ad753f18054f5b3089fcb588f3e3a092136a416b6c1502cb7e8877"}, {file = "eth_abi-5.2.0.tar.gz", hash = "sha256:178703fa98c07d8eecd5ae569e7e8d159e493ebb6eeb534a8fe973fbc4e40ef0"}, @@ -797,6 +818,7 @@ version = "0.13.7" description = "eth-account: Sign Ethereum transactions and messages with local private keys" optional = false python-versions = "<4,>=3.8" +groups = ["main"] files = [ {file = "eth_account-0.13.7-py3-none-any.whl", hash = "sha256:39727de8c94d004ff61d10da7587509c04d2dc7eac71e04830135300bdfc6d24"}, {file = "eth_account-0.13.7.tar.gz", hash = "sha256:5853ecbcbb22e65411176f121f5f24b8afeeaf13492359d254b16d8b18c77a46"}, @@ -825,6 +847,7 @@ version = "0.7.1" description = "eth-hash: The Ethereum hashing function, keccak256, sometimes (erroneously) called sha3" optional = false python-versions = "<4,>=3.8" +groups = ["main"] files = [ {file = "eth_hash-0.7.1-py3-none-any.whl", hash = "sha256:0fb1add2adf99ef28883fd6228eb447ef519ea72933535ad1a0b28c6f65f868a"}, {file = "eth_hash-0.7.1.tar.gz", hash = "sha256:d2411a403a0b0a62e8247b4117932d900ffb4c8c64b15f92620547ca5ce46be5"}, @@ -837,7 +860,7 @@ pycryptodome = {version = ">=3.6.6,<4", optional = true, markers = "extra == \"p dev = ["build (>=0.9.0)", "bump_my_version (>=0.19.0)", "ipython", "mypy (==1.10.0)", "pre-commit (>=3.4.0)", "pytest (>=7.0.0)", "pytest-xdist (>=2.4.0)", "sphinx (>=6.0.0)", "sphinx-autobuild (>=2021.3.14)", "sphinx_rtd_theme (>=1.0.0)", "towncrier (>=24,<25)", "tox (>=4.0.0)", "twine", "wheel"] docs = ["sphinx (>=6.0.0)", "sphinx-autobuild (>=2021.3.14)", "sphinx_rtd_theme (>=1.0.0)", "towncrier (>=24,<25)"] pycryptodome = ["pycryptodome (>=3.6.6,<4)"] -pysha3 = ["pysha3 (>=1.0.0,<2.0.0)", "safe-pysha3 (>=1.0.0)"] +pysha3 = ["pysha3 (>=1.0.0,<2.0.0) ; python_version < \"3.9\"", "safe-pysha3 (>=1.0.0) ; python_version >= \"3.9\""] test = ["pytest (>=7.0.0)", "pytest-xdist (>=2.4.0)"] [[package]] @@ -846,6 +869,7 @@ version = "0.8.1" description = "eth-keyfile: A library for handling the encrypted keyfiles used to store ethereum private keys" optional = false python-versions = "<4,>=3.8" +groups = ["main"] files = [ {file = "eth_keyfile-0.8.1-py3-none-any.whl", hash = "sha256:65387378b82fe7e86d7cb9f8d98e6d639142661b2f6f490629da09fddbef6d64"}, {file = "eth_keyfile-0.8.1.tar.gz", hash = "sha256:9708bc31f386b52cca0969238ff35b1ac72bd7a7186f2a84b86110d3c973bec1"}, @@ -867,6 +891,7 @@ version = "0.7.0" description = "eth-keys: Common API for Ethereum key operations" optional = false python-versions = "<4,>=3.8" +groups = ["main"] files = [ {file = "eth_keys-0.7.0-py3-none-any.whl", hash = "sha256:b0cdda8ffe8e5ba69c7c5ca33f153828edcace844f67aabd4542d7de38b159cf"}, {file = "eth_keys-0.7.0.tar.gz", hash = "sha256:79d24fd876201df67741de3e3fefb3f4dbcbb6ace66e47e6fe662851a4547814"}, @@ -888,6 +913,7 @@ version = "2.2.0" description = "eth-rlp: RLP definitions for common Ethereum objects in Python" optional = false python-versions = "<4,>=3.8" +groups = ["main"] files = [ {file = "eth_rlp-2.2.0-py3-none-any.whl", hash = "sha256:5692d595a741fbaef1203db6a2fedffbd2506d31455a6ad378c8449ee5985c47"}, {file = "eth_rlp-2.2.0.tar.gz", hash = "sha256:5e4b2eb1b8213e303d6a232dfe35ab8c29e2d3051b86e8d359def80cd21db83d"}, @@ -910,6 +936,7 @@ version = "4.0.0" description = "eth-typing: Common type annotations for ethereum python packages" optional = false python-versions = ">=3.8, <4" +groups = ["main"] files = [ {file = "eth-typing-4.0.0.tar.gz", hash = "sha256:9af0b6beafbc5c2e18daf19da5f5a68315023172c4e79d149e12ad10a3d3f731"}, {file = "eth_typing-4.0.0-py3-none-any.whl", hash = "sha256:7e556bea322b6e8c0a231547b736c258e10ce9eed5ddc254f51031b12af66a16"}, @@ -926,6 +953,7 @@ version = "4.1.1" description = "eth-utils: Common utility functions for python code that interacts with Ethereum" optional = false python-versions = "<4,>=3.8" +groups = ["main"] files = [ {file = "eth_utils-4.1.1-py3-none-any.whl", hash = "sha256:ccbbac68a6d65cb6e294c5bcb6c6a5cec79a241c56dc5d9c345ed788c30f8534"}, {file = "eth_utils-4.1.1.tar.gz", hash = "sha256:71c8d10dec7494aeed20fa7a4d52ec2ce4a2e52fdce80aab4f5c3c19f3648b25"}, @@ -948,6 +976,8 @@ version = "1.3.0" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version < \"3.11\"" files = [ {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, @@ -965,6 +995,7 @@ version = "6.1.0" description = "the modular source code checker: pep8 pyflakes and co" optional = false python-versions = ">=3.8.1" +groups = ["dev"] files = [ {file = "flake8-6.1.0-py2.py3-none-any.whl", hash = "sha256:ffdfce58ea94c6580c77888a86506937f9a1a227dfcd15f245d694ae20a6b6e5"}, {file = "flake8-6.1.0.tar.gz", hash = "sha256:d5b3857f07c030bdb5bf41c7f53799571d75c4491748a3adcd47de929e34cd23"}, @@ -981,6 +1012,7 @@ version = "1.7.0" description = "A list-like structure which implements collections.abc.MutableSequence" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "frozenlist-1.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cc4df77d638aa2ed703b878dd093725b72a824c3c546c076e8fdf276f78ee84a"}, {file = "frozenlist-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:716a9973a2cc963160394f701964fe25012600f3d311f60c790400b00e568b61"}, @@ -1094,6 +1126,7 @@ version = "2.1.0" description = "Copy your docs directly to the gh-pages branch." optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"}, {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"}, @@ -1111,6 +1144,7 @@ version = "1.3.1" description = "hexbytes: Python `bytes` subclass that decodes hex, with a readable console output" optional = false python-versions = "<4,>=3.8" +groups = ["main"] files = [ {file = "hexbytes-1.3.1-py3-none-any.whl", hash = "sha256:da01ff24a1a9a2b1881c4b85f0e9f9b0f51b526b379ffa23832ae7899d29c2c7"}, {file = "hexbytes-1.3.1.tar.gz", hash = "sha256:a657eebebdfe27254336f98d8af6e2236f3f83aed164b87466b6cf6c5f5a4765"}, @@ -1127,6 +1161,7 @@ version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" +groups = ["main", "dev"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -1135,35 +1170,13 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] -[[package]] -name = "importlib-metadata" -version = "8.7.0" -description = "Read metadata from Python packages" -optional = false -python-versions = ">=3.9" -files = [ - {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"}, - {file = "importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"}, -] - -[package.dependencies] -zipp = ">=3.20" - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -perf = ["ipython"] -test = ["flufl.flake8", "importlib_resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] -type = ["pytest-mypy"] - [[package]] name = "iniconfig" version = "2.1.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, @@ -1175,6 +1188,7 @@ version = "5.13.2" description = "A Python utility / library to sort Python imports." optional = false python-versions = ">=3.8.0" +groups = ["dev"] files = [ {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, @@ -1189,6 +1203,7 @@ version = "3.1.6" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, @@ -1206,6 +1221,7 @@ version = "4.24.0" description = "An implementation of JSON Schema validation for Python" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "jsonschema-4.24.0-py3-none-any.whl", hash = "sha256:a462455f19f5faf404a7902952b6f0e3ce868f3ee09a359b05eca6673bd8412d"}, {file = "jsonschema-4.24.0.tar.gz", hash = "sha256:0b4e8069eb12aedfa881333004bccaec24ecef5a8a6a4b6df142b2cc9599d196"}, @@ -1227,6 +1243,7 @@ version = "2025.4.1" description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "jsonschema_specifications-2025.4.1-py3-none-any.whl", hash = "sha256:4653bffbd6584f7de83a67e0d620ef16900b390ddc7939d56684d6c81e33f1af"}, {file = "jsonschema_specifications-2025.4.1.tar.gz", hash = "sha256:630159c9f4dbea161a6a2205c3011cc4f18ff381b189fff48bb39b9bf26ae608"}, @@ -1241,6 +1258,7 @@ version = "1.3.0" description = "An Dict like LRU container." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "lru-dict-1.3.0.tar.gz", hash = "sha256:54fd1966d6bd1fcde781596cb86068214edeebff1db13a2cea11079e3fd07b6b"}, {file = "lru_dict-1.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4073333894db9840f066226d50e6f914a2240711c87d60885d8c940b69a6673f"}, @@ -1334,14 +1352,12 @@ version = "3.8.2" description = "Python implementation of John Gruber's Markdown." optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "markdown-3.8.2-py3-none-any.whl", hash = "sha256:5c83764dbd4e00bdd94d85a19b8d55ccca20fe35b2e678a1422b380324dd5f24"}, {file = "markdown-3.8.2.tar.gz", hash = "sha256:247b9a70dd12e27f67431ce62523e675b866d254f900c4fe75ce3dda62237c45"}, ] -[package.dependencies] -importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} - [package.extras] docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] testing = ["coverage", "pyyaml"] @@ -1352,6 +1368,7 @@ version = "3.0.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, @@ -1376,6 +1393,7 @@ version = "3.0.2" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, @@ -1446,6 +1464,7 @@ version = "0.7.0" description = "McCabe checker, plugin for flake8" optional = false python-versions = ">=3.6" +groups = ["dev"] files = [ {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, @@ -1457,6 +1476,7 @@ version = "0.1.2" description = "Markdown URL utilities" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, @@ -1468,6 +1488,7 @@ version = "1.3.4" description = "A deep merge function for 🐍." optional = false python-versions = ">=3.6" +groups = ["dev"] files = [ {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, @@ -1479,6 +1500,7 @@ version = "1.6.1" description = "Project documentation with Markdown." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e"}, {file = "mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2"}, @@ -1488,7 +1510,6 @@ files = [ click = ">=7.0" colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""} ghp-import = ">=1.0" -importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} jinja2 = ">=2.11.1" markdown = ">=3.3.6" markupsafe = ">=2.0.1" @@ -1502,7 +1523,7 @@ watchdog = ">=2.0" [package.extras] i18n = ["babel (>=2.9.0)"] -min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-import (==1.0)", "importlib-metadata (==4.4)", "jinja2 (==2.11.1)", "markdown (==3.3.6)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "mkdocs-get-deps (==0.2.0)", "packaging (==20.5)", "pathspec (==0.11.1)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "watchdog (==2.0)"] +min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4) ; platform_system == \"Windows\"", "ghp-import (==1.0)", "importlib-metadata (==4.4) ; python_version < \"3.10\"", "jinja2 (==2.11.1)", "markdown (==3.3.6)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "mkdocs-get-deps (==0.2.0)", "packaging (==20.5)", "pathspec (==0.11.1)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "watchdog (==2.0)"] [[package]] name = "mkdocs-autorefs" @@ -1510,6 +1531,7 @@ version = "0.4.1" description = "Automatically link across pages in MkDocs." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "mkdocs-autorefs-0.4.1.tar.gz", hash = "sha256:70748a7bd025f9ecd6d6feeba8ba63f8e891a1af55f48e366d6d6e78493aba84"}, {file = "mkdocs_autorefs-0.4.1-py3-none-any.whl", hash = "sha256:a2248a9501b29dc0cc8ba4c09f4f47ff121945f6ce33d760f145d6f89d313f5b"}, @@ -1525,13 +1547,13 @@ version = "0.2.0" description = "MkDocs extension that lists all dependencies according to a mkdocs.yml file" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134"}, {file = "mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c"}, ] [package.dependencies] -importlib-metadata = {version = ">=4.3", markers = "python_version < \"3.10\""} mergedeep = ">=1.3.4" platformdirs = ">=2.2.0" pyyaml = ">=5.1" @@ -1542,6 +1564,7 @@ version = "3.9.1" description = "Mkdocs Markdown includer plugin." optional = false python-versions = ">=3.6" +groups = ["dev"] files = [ {file = "mkdocs_include_markdown_plugin-3.9.1-py3-none-any.whl", hash = "sha256:f33687e29ac66d045ba181ea50f054646b0090b42b0a4318f08e7f1d1235e6f6"}, {file = "mkdocs_include_markdown_plugin-3.9.1.tar.gz", hash = "sha256:5e5698e78d7fea111be9873a456089daa333497988405acaac8eba2924a19152"}, @@ -1557,6 +1580,7 @@ version = "8.5.11" description = "Documentation that simply works" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "mkdocs_material-8.5.11-py3-none-any.whl", hash = "sha256:c907b4b052240a5778074a30a78f31a1f8ff82d7012356dc26898b97559f082e"}, {file = "mkdocs_material-8.5.11.tar.gz", hash = "sha256:b0ea0513fd8cab323e8a825d6692ea07fa83e917bb5db042e523afecc7064ab7"}, @@ -1577,6 +1601,7 @@ version = "1.3.1" description = "Extension pack for Python Markdown and MkDocs Material." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31"}, {file = "mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443"}, @@ -1588,6 +1613,7 @@ version = "6.5.1" description = "multidict implementation" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "multidict-6.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7b7d75cb5b90fa55700edbbdca12cd31f6b19c919e98712933c7a1c3c6c71b73"}, {file = "multidict-6.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ad32e43e028276612bf5bab762677e7d131d2df00106b53de2efb2b8a28d5bce"}, @@ -1698,6 +1724,7 @@ version = "1.1.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, @@ -1709,6 +1736,7 @@ version = "1.26.4" description = "Fundamental package for array computing in Python" optional = false python-versions = ">=3.9" +groups = ["main", "dev"] files = [ {file = "numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0"}, {file = "numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a"}, @@ -1754,6 +1782,7 @@ version = "25.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, @@ -1765,6 +1794,7 @@ version = "2.3.0" description = "Powerful data structures for data analysis, time series, and statistics" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "pandas-2.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:625466edd01d43b75b1883a64d859168e4556261a5035b32f9d743b67ef44634"}, {file = "pandas-2.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a6872d695c896f00df46b71648eea332279ef4077a409e2fe94220208b6bb675"}, @@ -1851,6 +1881,7 @@ version = "0.10.0" description = "(Soon to be) the fastest pure-Python PEG parser I could muster" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "parsimonious-0.10.0-py3-none-any.whl", hash = "sha256:982ab435fabe86519b57f6b35610aa4e4e977e9f02a14353edf4bbc75369fc0f"}, {file = "parsimonious-0.10.0.tar.gz", hash = "sha256:8281600da180ec8ae35427a4ab4f7b82bfec1e3d1e52f80cb60ea82b9512501c"}, @@ -1865,6 +1896,7 @@ version = "0.12.1" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, @@ -1876,6 +1908,7 @@ version = "4.3.8" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4"}, {file = "platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc"}, @@ -1892,6 +1925,7 @@ version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, @@ -1907,6 +1941,7 @@ version = "0.3.2" description = "Accelerated property cache" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "propcache-0.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:22d9962a358aedbb7a2e36187ff273adeaab9743373a272976d2e348d08c7770"}, {file = "propcache-0.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0d0fda578d1dc3f77b6b5a5dce3b9ad69a8250a891760a548df850a5e8da87f3"}, @@ -2014,6 +2049,7 @@ version = "6.31.1" description = "" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "protobuf-6.31.1-cp310-abi3-win32.whl", hash = "sha256:7fa17d5a29c2e04b7d90e5e32388b8bfd0e7107cd8e616feef7ed3fa6bdab5c9"}, {file = "protobuf-6.31.1-cp310-abi3-win_amd64.whl", hash = "sha256:426f59d2964864a1a366254fa703b8632dcec0790d8862d30034d8245e1cd447"}, @@ -2032,6 +2068,7 @@ version = "2.11.1" description = "Python style guide checker" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pycodestyle-2.11.1-py2.py3-none-any.whl", hash = "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67"}, {file = "pycodestyle-2.11.1.tar.gz", hash = "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f"}, @@ -2043,6 +2080,7 @@ version = "3.23.0" description = "Cryptographic library for Python" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main"] files = [ {file = "pycryptodome-3.23.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a176b79c49af27d7f6c12e4b178b0824626f40a7b9fed08f712291b6d54bf566"}, {file = "pycryptodome-3.23.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:573a0b3017e06f2cffd27d92ef22e46aa3be87a2d317a5abf7cc0e84e321bd75"}, @@ -2093,6 +2131,7 @@ version = "2.11.7" description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b"}, {file = "pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db"}, @@ -2106,7 +2145,7 @@ typing-inspection = ">=0.4.0" [package.extras] email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] [[package]] name = "pydantic-core" @@ -2114,6 +2153,7 @@ version = "2.33.2" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, @@ -2225,6 +2265,7 @@ version = "3.1.0" description = "passive checker of Python programs" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pyflakes-3.1.0-py2.py3-none-any.whl", hash = "sha256:4132f6d49cb4dae6819e5379898f2b8cce3c5f23994194c24b77d5da2e36f774"}, {file = "pyflakes-3.1.0.tar.gz", hash = "sha256:a0aae034c444db0071aa077972ba4768d40c830d9539fd45bf4cd3f8f6992efc"}, @@ -2236,6 +2277,7 @@ version = "2.19.2" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, @@ -2250,6 +2292,7 @@ version = "10.16" description = "Extension pack for Python Markdown." optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "pymdown_extensions-10.16-py3-none-any.whl", hash = "sha256:f5dd064a4db588cb2d95229fc4ee63a1b16cc8b4d0e6145c0899ed8723da1df2"}, {file = "pymdown_extensions-10.16.tar.gz", hash = "sha256:71dac4fca63fabeffd3eb9038b756161a33ec6e8d230853d3cecf562155ab3de"}, @@ -2268,6 +2311,7 @@ version = "7.4.4" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, @@ -2290,6 +2334,7 @@ version = "13.0" description = "pytest plugin to re-run tests to eliminate flaky failures" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "pytest-rerunfailures-13.0.tar.gz", hash = "sha256:e132dbe420bc476f544b96e7036edd0a69707574209b6677263c950d19b09199"}, {file = "pytest_rerunfailures-13.0-py3-none-any.whl", hash = "sha256:34919cb3fcb1f8e5d4b940aa75ccdea9661bade925091873b7c6fa5548333069"}, @@ -2305,6 +2350,7 @@ version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main", "dev"] files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -2319,6 +2365,7 @@ version = "0.17.1" description = "Read key-value pairs from a .env file and set them as environment variables" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "python-dotenv-0.17.1.tar.gz", hash = "sha256:b1ae5e9643d5ed987fc57cc2583021e38db531946518130777734f9589b3141f"}, {file = "python_dotenv-0.17.1-py2.py3-none-any.whl", hash = "sha256:00aa34e92d992e9f8383730816359647f358f4a3be1ba45e5a5cefd27ee91544"}, @@ -2333,6 +2380,7 @@ version = "2025.2" description = "World timezone definitions, modern and historical" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00"}, {file = "pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3"}, @@ -2344,6 +2392,7 @@ version = "16.0.0" description = "Unicode normalization forms (NFC, NFKC, NFD, NFKD). A library independent of the Python core Unicode database." optional = false python-versions = ">=3.6" +groups = ["main"] files = [ {file = "pyunormalize-16.0.0-py3-none-any.whl", hash = "sha256:c647d95e5d1e2ea9a2f448d1d95d8518348df24eab5c3fd32d2b5c3300a49152"}, {file = "pyunormalize-16.0.0.tar.gz", hash = "sha256:2e1dfbb4a118154ae26f70710426a52a364b926c9191f764601f5a8cb12761f7"}, @@ -2355,6 +2404,8 @@ version = "310" description = "Python for Window Extensions" optional = false python-versions = "*" +groups = ["main"] +markers = "platform_system == \"Windows\"" files = [ {file = "pywin32-310-cp310-cp310-win32.whl", hash = "sha256:6dd97011efc8bf51d6793a82292419eba2c71cf8e7250cfac03bba284454abc1"}, {file = "pywin32-310-cp310-cp310-win_amd64.whl", hash = "sha256:c3e78706e4229b915a0821941a84e7ef420bf2b77e08c9dae3c76fd03fd2ae3d"}, @@ -2380,6 +2431,7 @@ version = "6.0.2" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, @@ -2442,6 +2494,7 @@ version = "1.1" description = "A custom YAML tag for referencing environment variables in YAML files." optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04"}, {file = "pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff"}, @@ -2456,6 +2509,7 @@ version = "0.36.2" description = "JSON Referencing + Python" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0"}, {file = "referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa"}, @@ -2472,6 +2526,7 @@ version = "2024.11.6" description = "Alternative regular expression module, to replace re." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff590880083d60acc0433f9c3f713c51f7ac6ebb9adf889c79a261ecf541aa91"}, {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:658f90550f38270639e83ce492f27d2c8d2cd63805c65a13a14d36ca126753f0"}, @@ -2575,6 +2630,7 @@ version = "2.32.4" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, @@ -2596,6 +2652,7 @@ version = "14.0.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.8.0" +groups = ["main"] files = [ {file = "rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0"}, {file = "rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725"}, @@ -2615,6 +2672,7 @@ version = "1.8.9" description = "Format click help output nicely with rich" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "rich_click-1.8.9-py3-none-any.whl", hash = "sha256:c3fa81ed8a671a10de65a9e20abf642cfdac6fdb882db1ef465ee33919fbcfe2"}, {file = "rich_click-1.8.9.tar.gz", hash = "sha256:fd98c0ab9ddc1cf9c0b7463f68daf28b4d0033a74214ceb02f761b3ff2af3136"}, @@ -2635,6 +2693,7 @@ version = "4.1.0" description = "rlp: A package for Recursive Length Prefix encoding and decoding" optional = false python-versions = "<4,>=3.8" +groups = ["main"] files = [ {file = "rlp-4.1.0-py3-none-any.whl", hash = "sha256:8eca394c579bad34ee0b937aecb96a57052ff3716e19c7a578883e767bc5da6f"}, {file = "rlp-4.1.0.tar.gz", hash = "sha256:be07564270a96f3e225e2c107db263de96b5bc1f27722d2855bd3459a08e95a9"}, @@ -2655,6 +2714,7 @@ version = "0.25.1" description = "Python bindings to Rust's persistent data structures (rpds)" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "rpds_py-0.25.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:f4ad628b5174d5315761b67f212774a32f5bad5e61396d38108bd801c0a8f5d9"}, {file = "rpds_py-0.25.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8c742af695f7525e559c16f1562cf2323db0e3f0fbdcabdf6865b095256b2d40"}, @@ -2781,6 +2841,7 @@ version = "0.7.7" description = "Simple data validation library" optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "schema-0.7.7-py2.py3-none-any.whl", hash = "sha256:5d976a5b50f36e74e2157b47097b60002bd4d42e65425fcc9c9befadb4255dde"}, {file = "schema-0.7.7.tar.gz", hash = "sha256:7da553abd2958a19dc2547c388cde53398b39196175a9be59ea1caf5ab0a1807"}, @@ -2792,6 +2853,7 @@ version = "2.13.0" description = "Python helper for Semantic Versioning (http://semver.org/)" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["dev"] files = [ {file = "semver-2.13.0-py2.py3-none-any.whl", hash = "sha256:ced8b23dceb22134307c1b8abfa523da14198793d9787ac838e70e29e77458d4"}, {file = "semver-2.13.0.tar.gz", hash = "sha256:fa0fe2722ee1c3f57eac478820c3a5ae2f624af8264cbdf9000c980ff7f75e3f"}, @@ -2803,19 +2865,20 @@ version = "75.9.1" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "setuptools-75.9.1-py3-none-any.whl", hash = "sha256:0a6f876d62f4d978ca1a11ab4daf728d1357731f978543ff18ecdbf9fd071f73"}, {file = "setuptools-75.9.1.tar.gz", hash = "sha256:b6eca2c3070cdc82f71b4cb4bb2946bc0760a210d11362278cf1ff394e6ea32c"}, ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.8.0)"] -core = ["importlib_metadata (>=6)", "jaraco.collections", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] +core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.collections", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] enabler = ["pytest-enabler (>=2.2)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib_metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.14.*)", "pytest-mypy"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] [[package]] name = "six" @@ -2823,6 +2886,7 @@ version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main", "dev"] files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, @@ -2834,6 +2898,7 @@ version = "0.9.0" description = "Pretty-print tabular data" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f"}, {file = "tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c"}, @@ -2848,6 +2913,7 @@ version = "6.11.0" description = "Bump software releases" optional = false python-versions = ">=3.7,<4.0" +groups = ["dev"] files = [ {file = "tbump-6.11.0-py3-none-any.whl", hash = "sha256:6b181fe6f3ae84ce0b9af8cc2009a8bca41ded34e73f623a7413b9684f1b4526"}, {file = "tbump-6.11.0.tar.gz", hash = "sha256:385e710eedf0a8a6ff959cf1e9f3cfd17c873617132fc0ec5f629af0c355c870"}, @@ -2865,6 +2931,8 @@ version = "2.2.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version < \"3.11\"" files = [ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, @@ -2906,6 +2974,7 @@ version = "0.11.8" description = "Style preserving TOML library" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "tomlkit-0.11.8-py3-none-any.whl", hash = "sha256:8c726c4c202bdb148667835f68d68780b9a003a9ec34167b6c673b38eff2a171"}, {file = "tomlkit-0.11.8.tar.gz", hash = "sha256:9330fc7faa1db67b541b28e62018c17d20be733177d290a13b24c62d1614e0c3"}, @@ -2917,6 +2986,8 @@ version = "1.0.0" description = "List processing tools and functional utilities" optional = false python-versions = ">=3.8" +groups = ["main"] +markers = "implementation_name == \"cpython\" or implementation_name == \"pypy\"" files = [ {file = "toolz-1.0.0-py3-none-any.whl", hash = "sha256:292c8f1c4e7516bf9086f8850935c799a874039c8bcf959d47b600e4c44a6236"}, {file = "toolz-1.0.0.tar.gz", hash = "sha256:2c86e3d9a04798ac556793bced838816296a2f085017664e4995cb40a1047a02"}, @@ -2928,10 +2999,12 @@ version = "4.14.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" +groups = ["main", "dev"] files = [ {file = "typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af"}, {file = "typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4"}, ] +markers = {dev = "python_version < \"3.11\""} [[package]] name = "typing-inspection" @@ -2939,6 +3012,7 @@ version = "0.4.1" description = "Runtime typing introspection tools" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, @@ -2953,6 +3027,7 @@ version = "2025.2" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" +groups = ["main"] files = [ {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, @@ -2964,6 +3039,7 @@ version = "1.4.0" description = "ASCII transliterations of Unicode text" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "Unidecode-1.4.0-py3-none-any.whl", hash = "sha256:c3c7606c27503ad8d501270406e345ddb480a7b5f38827eafe4fa82a137f0021"}, {file = "Unidecode-1.4.0.tar.gz", hash = "sha256:ce35985008338b676573023acc382d62c264f307c8f7963733405add37ea2b23"}, @@ -2975,13 +3051,14 @@ version = "2.5.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" +groups = ["main", "dev"] files = [ {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] @@ -2992,6 +3069,7 @@ version = "6.0.0" description = "Filesystem events monitoring" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26"}, {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112"}, @@ -3034,6 +3112,7 @@ version = "6.11.0" description = "web3.py" optional = false python-versions = ">=3.7.2" +groups = ["main"] files = [ {file = "web3-6.11.0-py3-none-any.whl", hash = "sha256:44e79da6a4765eacf137f2f388e37aa0c1e24a93bdfb462cffe9441d1be3d509"}, {file = "web3-6.11.0.tar.gz", hash = "sha256:050dea52ae73d787272e7ecba7249f096595938c90cce1a384c20375c6b0f720"}, @@ -3057,7 +3136,7 @@ typing-extensions = ">=4.0.1" websockets = ">=10.0.0" [package.extras] -dev = ["black (>=22.1.0)", "build (>=0.9.0)", "bumpversion", "eth-tester[py-evm] (==v0.9.1-b.1)", "flake8 (==3.8.3)", "flaky (>=3.7.0)", "hypothesis (>=3.31.2)", "importlib-metadata (<5.0)", "ipfshttpclient (==0.8.0a2)", "isort (>=5.11.0)", "mypy (==1.4.1)", "py-geth (>=3.11.0)", "pytest (>=7.0.0)", "pytest-asyncio (>=0.18.1)", "pytest-mock (>=1.10)", "pytest-watch (>=4.2)", "pytest-xdist (>=1.29)", "setuptools (>=38.6.0)", "sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.0.0)", "towncrier (>=21,<22)", "tox (>=3.18.0)", "tqdm (>4.32)", "twine (>=1.13)", "types-protobuf (==3.19.13)", "types-requests (>=2.26.1)", "types-setuptools (>=57.4.4)", "when-changed (>=0.3.0)"] +dev = ["black (>=22.1.0)", "build (>=0.9.0)", "bumpversion", "eth-tester[py-evm] (==v0.9.1-b.1)", "flake8 (==3.8.3)", "flaky (>=3.7.0)", "hypothesis (>=3.31.2)", "importlib-metadata (<5.0) ; python_version < \"3.8\"", "ipfshttpclient (==0.8.0a2)", "isort (>=5.11.0)", "mypy (==1.4.1)", "py-geth (>=3.11.0)", "pytest (>=7.0.0)", "pytest-asyncio (>=0.18.1)", "pytest-mock (>=1.10)", "pytest-watch (>=4.2)", "pytest-xdist (>=1.29)", "setuptools (>=38.6.0)", "sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.0.0)", "towncrier (>=21,<22)", "tox (>=3.18.0)", "tqdm (>4.32)", "twine (>=1.13)", "types-protobuf (==3.19.13)", "types-requests (>=2.26.1)", "types-setuptools (>=57.4.4)", "when-changed (>=0.3.0)"] docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.0.0)", "towncrier (>=21,<22)"] ipfs = ["ipfshttpclient (==0.8.0a2)"] linter = ["black (>=22.1.0)", "flake8 (==3.8.3)", "isort (>=5.11.0)", "mypy (==1.4.1)", "types-protobuf (==3.19.13)", "types-requests (>=2.26.1)", "types-setuptools (>=57.4.4)"] @@ -3069,6 +3148,7 @@ version = "0.59.0" description = "WebSocket client for Python with low level API options" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["main"] files = [ {file = "websocket-client-0.59.0.tar.gz", hash = "sha256:d376bd60eace9d437ab6d7ee16f4ab4e821c9dae591e1b783c58ebd8aaf80c5c"}, {file = "websocket_client-0.59.0-py2.py3-none-any.whl", hash = "sha256:2e50d26ca593f70aba7b13a489435ef88b8fc3b5c5643c1ce8808ff9b40f0b32"}, @@ -3083,6 +3163,7 @@ version = "15.0.1" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b"}, {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205"}, @@ -3161,6 +3242,7 @@ version = "1.20.1" description = "Yet another URL library" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "yarl-1.20.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6032e6da6abd41e4acda34d75a816012717000fa6839f37124a47fcefc49bec4"}, {file = "yarl-1.20.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2c7b34d804b8cf9b214f05015c4fee2ebe7ed05cf581e7192c06555c71f4446a"}, @@ -3273,26 +3355,7 @@ idna = ">=2.0" multidict = ">=4.0" propcache = ">=0.2.1" -[[package]] -name = "zipp" -version = "3.23.0" -description = "Backport of pathlib-compatible object wrapper for zip files" -optional = false -python-versions = ">=3.9" -files = [ - {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, - {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] -type = ["pytest-mypy"] - [metadata] -lock-version = "2.0" -python-versions = ">=3.9,<=3.12" -content-hash = "b881eb7fc55ca464057b0c20f6daf1d00383590abaf634eafcc1f1b3596d1281" +lock-version = "2.1" +python-versions = ">=3.10,<=3.12" +content-hash = "0ad2598809081b0f65c04d9b518d4629a45294944cfd6edb48c2ef198f1245ab" diff --git a/pyproject.toml b/pyproject.toml index 7e9332ff..6be205ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ readme = "README.md" packages = [{ include = "derive_client" }] [tool.poetry.dependencies] -python = ">=3.9,<=3.12" +python = ">=3.10,<=3.12" requests = "^2" web3 = { version = ">=6,<8" } websocket-client = ">=0.32.0,<1" From 06d63e3c4f919bfa1902f53834c2bd9f13f83986 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Fri, 8 Aug 2025 14:04:54 +0200 Subject: [PATCH 003/101] feat: poetry add returns --- poetry.lock | 21 ++++++++++++++++++++- pyproject.toml | 1 + 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index 26d0f36e..c59ecfd9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2646,6 +2646,25 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "returns" +version = "0.26.0" +description = "Make your functions return something meaningful, typed, and safe!" +optional = false +python-versions = "<4.0,>=3.10" +groups = ["main"] +files = [ + {file = "returns-0.26.0-py3-none-any.whl", hash = "sha256:7cae94c730d6c56ffd9d0f583f7a2c0b32cfe17d141837150c8e6cff3eb30d71"}, + {file = "returns-0.26.0.tar.gz", hash = "sha256:180320e0f6e9ea9845330ccfc020f542330f05b7250941d9b9b7c00203fcc3da"}, +] + +[package.dependencies] +typing-extensions = ">=4.0,<5.0" + +[package.extras] +check-laws = ["hypothesis (>=6.136,<7.0)", "pytest (>=8.0,<9.0)"] +compatible-mypy = ["mypy (>=1.12,<1.18)"] + [[package]] name = "rich" version = "14.0.0" @@ -3358,4 +3377,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = ">=3.10,<=3.12" -content-hash = "0ad2598809081b0f65c04d9b518d4629a45294944cfd6edb48c2ef198f1245ab" +content-hash = "f95b97d86d748bd340e4c19112b139e8ce2e459f87ed2f192e750a03f6cbe8e1" diff --git a/pyproject.toml b/pyproject.toml index 6be205ca..022e6b4b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ pandas = ">=1,<=3" eth-account = ">=0.13" derive-action-signing = "^0.0.11" pydantic = "^2.11.3" +returns = "^0.26.0" [tool.poetry.scripts] drv = "derive_client.cli:cli" From 60c54541605660bd32e47cc5ab5bd303479e19b7 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Fri, 8 Aug 2025 14:55:59 +0200 Subject: [PATCH 004/101] feat: modify bridging methods on BaseClient to return a Result --- derive_client/clients/base_client.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/derive_client/clients/base_client.py b/derive_client/clients/base_client.py index 50d9361e..16c0ba97 100644 --- a/derive_client/clients/base_client.py +++ b/derive_client/clients/base_client.py @@ -22,6 +22,7 @@ from derive_action_signing.utils import MAX_INT_32, get_action_nonce, sign_rest_auth_header, utc_now_ms from pydantic import validate_call from web3 import Web3 +from returns.result import Result, safe from derive_client._bridge import BridgeClient from derive_client.constants import CONFIGS, DEFAULT_REFERER, PUBLIC_HEADERS, TOKEN_DECIMALS @@ -134,8 +135,9 @@ def create_account(self, wallet): raise Exception(result_code["error"]) return True + @safe @validate_call - def deposit_to_derive(self, chain_id: ChainID, currency: Currency, amount: float) -> BridgeTxResult: + def deposit_to_derive(self, chain_id: ChainID, currency: Currency, amount: float) -> Result[BridgeTxResult, Exception]: """ Submit a deposit into the Derive chain funding contract and return its initial BridgeTxResult without waiting for completion. @@ -154,8 +156,9 @@ def deposit_to_derive(self, chain_id: ChainID, currency: Currency, amount: float return client.deposit(amount=amount, currency=currency) + @safe @validate_call - def withdraw_from_derive(self, chain_id: ChainID, currency: Currency, amount: float) -> BridgeTxResult: + def withdraw_from_derive(self, chain_id: ChainID, currency: Currency, amount: float) -> Result[BridgeTxResult, Exception]: """ Submit a withdrawal from the Derive chain funding contract and return its initial BridgeTxResult without waiting for completion. @@ -174,7 +177,9 @@ def withdraw_from_derive(self, chain_id: ChainID, currency: Currency, amount: fl return client.withdraw_with_wrapper(amount=amount, currency=currency) - def poll_bridge_progress(self, tx_result: BridgeTxResult) -> BridgeTxResult: + @safe + @validate_call + def poll_bridge_progress(self, tx_result: BridgeTxResult) -> Result[BridgeTxResult, Exception]: """ Given a pending BridgeTxResult, return a new BridgeTxResult with updated status. Raises AlreadyFinalizedError if tx_result is not in PENDING status. From c095c4ac1fe7ddc04d429d6dbccfb5e20873fde0 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Fri, 8 Aug 2025 14:56:19 +0200 Subject: [PATCH 005/101] fix: update withdraw and deposit cli commands --- derive_client/cli.py | 63 ++++++++++++++++++++++++++++---------------- 1 file changed, 40 insertions(+), 23 deletions(-) diff --git a/derive_client/cli.py b/derive_client/cli.py index c69688c8..d4d3ab72 100644 --- a/derive_client/cli.py +++ b/derive_client/cli.py @@ -10,6 +10,7 @@ import rich_click as click from dotenv import load_dotenv from rich import print +from returns.result import Success, Failure from derive_client.analyser import PortfolioAnalyser from derive_client.clients.base_client import BaseClient @@ -157,18 +158,26 @@ def deposit(ctx, chain_id, currency, amount): client: BaseClient = ctx.obj["client"] - bridge_tx_result = client.deposit_to_derive(chain_id=chain_id, currency=currency, amount=amount) - bridge_tx_result = client.poll_bridge_progress(bridge_tx_result) + result = ( + client + .deposit_to_derive(chain_id=chain_id, currency=currency, amount=amount) + .bind(client.poll_bridge_progress) + .alt(lambda exc: click.ClickException(f"Error attempting to deposit: {exc}")) + ) - match bridge_tx_result.status: - case TxStatus.SUCCESS: - print(f"[bold green]Bridging {currency.name} from {chain_id.name} to DERIVE successful![/bold green]") - case TxStatus.FAILED: - print(f"[bold red]Bridging {currency.name} from {chain_id.name} to DERIVE failed.[/bold red]") - case TxStatus.PENDING: - print(f"[yellow]Bridging {currency.name} from {chain_id.name} to DERIVE is pending...[/yellow]") - case _: - raise click.ClickException(f"Exception attempting to deposit:\n{bridge_tx_result}") + match result: + case Success(bridge_tx_result): + match bridge_tx_result.status: + case TxStatus.SUCCESS: + print(f"[bold green]Bridging {currency.name} from {chain_id.name} to DERIVE successful![/bold green]") + case TxStatus.FAILED: + print(f"[bold red]Bridging {currency.name} from {chain_id.name} to DERIVE failed.[/bold red]") + case TxStatus.PENDING: + print(f"[yellow]Bridging {currency.name} from {chain_id.name} to DERIVE is pending...[/yellow]") + case _: + raise click.ClickException(f"Exception attempting to deposit:\n{bridge_tx_result}") + case Failure(exc): + raise exc @bridge.command("withdraw") @@ -207,18 +216,26 @@ def withdraw(ctx, chain_id, currency, amount): client: DeriveClient = ctx.obj["client"] - bridge_tx_result = client.withdraw_from_derive(chain_id=chain_id, currency=currency, amount=amount) - bridge_tx_result = client.poll_bridge_progress(bridge_tx_result) - - match bridge_tx_result.status: - case TxStatus.SUCCESS: - print(f"[bold green]Bridging {currency.name} from DERIVE to {chain_id.name} successful![/bold green]") - case TxStatus.FAILED: - print(f"[bold red]Bridging {currency.name} from DERIVE to {chain_id.name} failed.[/bold red]") - case TxStatus.PENDING: - print(f"[yellow]Bridging {currency.name} from DERIVE to {chain_id.name} is pending...[/yellow]") - case _: - raise click.ClickException(f"Exception attempting to withdraw:\n{bridge_tx_result}") + result = ( + client + .withdraw_from_derive(chain_id=chain_id, currency=currency, amount=amount) + .bind(client.poll_bridge_progress) + .alt(lambda exc: click.ClickException(f"Error attempting to withdraw: {exc}")) + ) + + match result: + case Success(bridge_tx_result): + match bridge_tx_result.status: + case TxStatus.SUCCESS: + print(f"[bold green]Bridging {currency.name} from DERIVE to {chain_id.name} successful![/bold green]") + case TxStatus.FAILED: + print(f"[bold red]Bridging {currency.name} from DERIVE to {chain_id.name} failed.[/bold red]") + case TxStatus.PENDING: + print(f"[yellow]Bridging {currency.name} from DERIVE to {chain_id.name} is pending...[/yellow]") + case _: + raise click.ClickException(f"Exception attempting to withdraw:\n{bridge_tx_result}") + case Failure(exc): + raise exc @cli.group("instruments") From b5618a3bf4c89540b8cd18750476a55f4b69a99a Mon Sep 17 00:00:00 2001 From: zarathustra Date: Fri, 8 Aug 2025 15:40:18 +0200 Subject: [PATCH 006/101] feat: remove TxStatus.ERROR and TxResult.exception --- derive_client/data_types/enums.py | 1 - derive_client/data_types/models.py | 20 -------------------- 2 files changed, 21 deletions(-) diff --git a/derive_client/data_types/enums.py b/derive_client/data_types/enums.py index 643f265b..edd54b31 100644 --- a/derive_client/data_types/enums.py +++ b/derive_client/data_types/enums.py @@ -7,7 +7,6 @@ class TxStatus(IntEnum): FAILED = 0 # confirmed and status == 0 (on-chain revert) SUCCESS = 1 # confirmed and status == 1 PENDING = 2 # not yet confirmed, no receipt - ERROR = 3 # local error, e.g. connection, invalid tx class DeriveTxStatus(Enum): diff --git a/derive_client/data_types/models.py b/derive_client/data_types/models.py index 2919dbdd..5d8fc11d 100644 --- a/derive_client/data_types/models.py +++ b/derive_client/data_types/models.py @@ -15,23 +15,6 @@ from .enums import BridgeType, ChainID, Currency, DeriveTxStatus, MainnetCurrency, MarginType, SessionKeyScope, TxStatus -class PException(Exception): - - @classmethod - def __get_pydantic_core_schema__(cls, _source, _handler: GetCoreSchemaHandler): - return core_schema.no_info_plain_validator_function(cls._validate) - - @classmethod - def __get_pydantic_json_schema__(cls, _schema, _handler: GetJsonSchemaHandler) -> dict: - return {"type": "string", "description": "An arbitrary Python Exception; serialized via str()"} - - @classmethod - def _validate(cls, v) -> Exception: - if not isinstance(v, Exception): - raise TypeError(f"Expected Exception, got {v!r}") - return v - - class PAttributeDict(AttributeDict): @classmethod @@ -172,14 +155,11 @@ def target_chain(self) -> ChainID: class TxResult: tx_hash: TxHash tx_receipt: PAttributeDict | None = None - exception: PException | None = None @property def status(self) -> TxStatus: if self.tx_receipt is not None: return TxStatus(int(self.tx_receipt.status)) # ∈ {0, 1} (EIP-658) - if self.exception is not None and not isinstance(self.exception, TimeoutError): - return TxStatus.ERROR return TxStatus.PENDING From a8a181ecabb3693bf25803c5a22b7d7e823c0a65 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Fri, 8 Aug 2025 15:41:34 +0200 Subject: [PATCH 007/101] fix: BridgeClient.poll_bridge_progress exception handling --- derive_client/_bridge/client.py | 48 +++++++++++++++------------------ 1 file changed, 21 insertions(+), 27 deletions(-) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index e4d3e81a..921a6789 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -7,6 +7,7 @@ import copy import functools import json +from contextlib import suppress from logging import Logger from typing import Literal @@ -508,6 +509,7 @@ def matching_message_id(log: AttributeDict) -> bool: return wait_for_event(context.target_w3, filter_params, condition=matching_message_id, logger=self.logger) def poll_bridge_progress(self, tx_result: BridgeTxResult) -> BridgeTxResult: + if tx_result.status is not TxStatus.PENDING: raise AlreadyFinalizedError(f"Bridge already in final state: {tx_result.status.name}") @@ -528,44 +530,34 @@ def poll_bridge_progress(self, tx_result: BridgeTxResult) -> BridgeTxResult: currency=tx_result.currency, ) - # 1. TimeoutError as exception during source_tx.tx_receipt - if not tx_result.source_tx.tx_receipt: - self.logger.info( - f"⏳ Checking source chain [{tx_result.source_chain.name}] tx receipt for {tx_result.source_tx.tx_hash}" - ) - tx_result.source_tx.exception = None - try: + # Timeout means partial update; subsequent steps depend on prior success, so we stop here + with suppress(TimeoutError): + # 1. TimeoutError as exception during source_tx.tx_receipt + if not tx_result.source_tx.tx_receipt: + self.logger.info( + f"⏳ Checking source chain [{tx_result.source_chain.name}] tx receipt for {tx_result.source_tx.tx_hash}" + ) tx_result.source_tx.tx_receipt = wait_for_tx_receipt( w3=context.source_w3, tx_hash=tx_result.source_tx.tx_hash ) - except TimeoutError as e: - tx_result.source_tx.exception = e - return tx_result - # 2. target_tx is None (i.e. TimeoutError when waiting for event log on target chain) - if not tx_result.target_tx: - try: + # 2. target_tx is None (i.e. TimeoutError when waiting for event log on target chain) + if not tx_result.target_tx: event_log = fetch_event(tx_result, context) tx_result.target_tx = TxResult(event_log["transactionHash"].to_0x_hex()) - except TimeoutError: - return tx_result - # 3. Timeout waiting for target_tx.tx_receipt - if not tx_result.target_tx.tx_receipt: - self.logger.info( - f"⏳ Checking target chain [{tx_result.target_chain.name}] tx receipt for {tx_result.target_tx.tx_hash}" - ) - tx_result.target_tx.exception = None - try: + # 3. TimeoutError waiting for target_tx.tx_receipt + if not tx_result.target_tx.tx_receipt: + self.logger.info( + f"⏳ Checking target chain [{tx_result.target_chain.name}] tx receipt for {tx_result.target_tx.tx_hash}" + ) tx_result.target_tx.tx_receipt = wait_for_tx_receipt( w3=context.target_w3, tx_hash=tx_result.target_tx.tx_hash ) - except TimeoutError as e: - tx_result.target_tx.exception = e return tx_result - def _ensure_derive_eth_balance(self, tx:dict[str, str]): + def _ensure_derive_eth_balance(self, tx: dict[str, str]): """Ensure that the Derive EOA wallet has sufficient ETH balance for gas.""" balance_of_owner = self.derive_w3.eth.get_balance(self.owner) required_gas = tx['maxFeePerGas'] * tx['gas'] @@ -594,8 +586,10 @@ def bridge_mainnet_eth_to_derive(self, amount: int) -> TxResult: ) require_gas = tx['maxFeePerGas'] * tx['gas'] current_balance = w3.eth.get_balance(self.account.address) - if not current_balance >= (amount + require_gas ) * 1.1: - raise InsufficientGas(f"Insufficient ETH balance for bridging amount {amount} + gas {require_gas}. Balance: {current_balance}") + if not current_balance >= (amount + require_gas) * 1.1: + raise InsufficientGas( + f"Insufficient ETH balance for bridging amount {amount} + gas {require_gas}. Balance: {current_balance}" + ) tx_result = send_and_confirm_tx( w3=w3, tx=tx, private_key=self.private_key, action="bridgeETH()", logger=self.logger ) From d96df23c40c4b6e06008a2856072ba61630b428e Mon Sep 17 00:00:00 2001 From: zarathustra Date: Fri, 8 Aug 2025 15:59:41 +0200 Subject: [PATCH 008/101] fix: improve usage of custom exceptions in BridgeClient --- derive_client/_bridge/client.py | 16 +++++++++++----- derive_client/exceptions.py | 4 ++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index 921a6789..2c64163f 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -61,7 +61,13 @@ TxResult, TxStatus, ) -from derive_client.exceptions import AlreadyFinalizedError, BridgeEventParseError, BridgeRouteError, InsufficientGas +from derive_client.exceptions import ( + AlreadyFinalizedError, + BridgeEventParseError, + BridgePrimarySignerRequiredError, + BridgeRouteError, + InsufficientGas, +) from derive_client.utils import ( build_standard_transaction, get_contract, @@ -127,7 +133,7 @@ def __init__(self, env: Environment, chain_id: ChainID, account: Account, wallet self.light_account = _load_light_account(w3=self.derive_w3, wallet=wallet) self.logger = logger if self.owner != self.account.address: - raise ValueError( + raise BridgePrimarySignerRequiredError( "Bridging disabled for secondary session-key signers: old-style assets " "(USDC, USDT) on Derive cannot specify a custom receiver. Using a " "secondary signer routes funds to the session key's contract instead of " @@ -215,7 +221,7 @@ def _make_bridge_context( src_event, tgt_event = src_socket.events.MessageOutbound(), tgt_socket.events.ExecutionSuccess() return BridgeContext(src_w3, tgt_w3, token_contract, src_event, tgt_event) - raise ValueError(f"Unsupported bridge_type={bridge_type} for currency={currency}.") + raise BridgeRouteError(f"Unsupported bridge_type={bridge_type} for currency={currency}.") def _resolve_socket_route( self, @@ -238,7 +244,7 @@ def _resolve_socket_route( msg = f"Target chain {tgt_chain.name} not found in {src_chain.name} connectors." raise BridgeRouteError(msg) if src_chain not in tgt_token_data.connectors: - msg = f"Target chain {src_chain.name} not found in {tgt_chain.name} connectors." + msg = f"Source chain {src_chain.name} not found in {tgt_chain.name} connectors." raise BridgeRouteError(msg) return src_token_data, src_token_data.connectors[tgt_chain][TARGET_SPEED] @@ -521,7 +527,7 @@ def poll_bridge_progress(self, tx_result: BridgeTxResult) -> BridgeTxResult: BridgeType.LAYERZERO: self.fetch_lz_event_log, } if (fetch_event := bridge_event_fetchers.get(tx_result.bridge)) is None: - raise ValueError(f"Invalid bridge_type: {tx_result.bridge}") + raise BridgeRouteError(f"Invalid bridge_type: {tx_result.bridge}") direction = "withdraw" if tx_result.source_chain == ChainID.DERIVE else "deposit" context = self._make_bridge_context( diff --git a/derive_client/exceptions.py b/derive_client/exceptions.py index 2c3cbc6a..7cfc0873 100644 --- a/derive_client/exceptions.py +++ b/derive_client/exceptions.py @@ -55,3 +55,7 @@ class NoAvailableRPC(Exception): class InsufficientGas(Exception): """Raised when a minimum gas requirement is not met.""" + + +class BridgePrimarySignerRequiredError(Exception): + """Raised when bridging is attempted with a secondary session-key signer.""" From 79efb5cb5c1a8520794662a07eb0d6dbae5a1e44 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Fri, 8 Aug 2025 16:04:11 +0200 Subject: [PATCH 009/101] fix: remove tx_result.exception from send_and_confirm_tx --- derive_client/utils/w3.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/derive_client/utils/w3.py b/derive_client/utils/w3.py index 1239f04d..9a60e230 100644 --- a/derive_client/utils/w3.py +++ b/derive_client/utils/w3.py @@ -250,21 +250,17 @@ def send_and_confirm_tx( """Send and confirm transactions.""" try: - tx_hash = sign_and_send_tx(w3=w3, tx=tx, private_key=private_key, logger=logger) tx_result = TxResult(tx_hash=tx_hash.to_0x_hex(), tx_receipt=None, exception=None) - except Exception as send_err: msg = f"❌ Failed to send tx for {action}, error: {send_err!r}" logger.error(msg) raise TxSubmissionError(msg) from send_err try: - tx_receipt = wait_for_tx_receipt(w3=w3, tx_hash=tx_hash) - tx_result.tx_receipt = tx_receipt - except TimeoutError as timeout_err: + tx_result.tx_receipt = wait_for_tx_receipt(w3=w3, tx_hash=tx_hash) + except TimeoutError: logger.warning(f"⏱️ Timeout waiting for tx receipt of {tx_hash.to_0x_hex()}") - tx_result.exception = timeout_err return tx_result if tx_result.tx_receipt.status == TxStatus.SUCCESS: From c0c6ba546760c962b12857815532f0987f0e39d4 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Fri, 8 Aug 2025 16:47:10 +0200 Subject: [PATCH 010/101] fix: improve gas estimation using GAS_LIMIT_BUFFER --- derive_client/_bridge/client.py | 4 ---- derive_client/_bridge/transaction.py | 15 +++------------ derive_client/constants.py | 1 + derive_client/exceptions.py | 8 ++++++-- derive_client/utils/w3.py | 10 ++++------ 5 files changed, 14 insertions(+), 24 deletions(-) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index 2c64163f..6806d295 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -17,7 +17,6 @@ from web3.datastructures import AttributeDict from derive_client._bridge.transaction import ( - _check_gas_balance, ensure_allowance, ensure_balance, prepare_mainnet_to_derive_gas_tx, @@ -27,7 +26,6 @@ CONTROLLER_ABI_PATH, CONTROLLER_V0_ABI_PATH, DEFAULT_GAS_FUNDING_AMOUNT, - DEPOSIT_GAS_LIMIT, DEPOSIT_HELPER_ABI_PATH, DEPOSIT_HOOK_ABI_PATH, DERIVE_ABI_PATH, @@ -260,7 +258,6 @@ def deposit(self, amount: int, currency: Currency) -> BridgeTxResult: target_from_block = context.target_w3.eth.block_number spender = token_data.Vault if token_data.isNewBridge else self.deposit_helper.address - _check_gas_balance(context.source_w3, self.owner) ensure_balance(context.source_token, self.owner, amount) ensure_allowance( w3=context.source_w3, @@ -360,7 +357,6 @@ def deposit_drv(self, amount: int, currency: Currency) -> BridgeTxResult: target_from_block = context.target_w3.eth.block_number # check allowance, if needed approve - _check_gas_balance(context.source_w3, self.owner) ensure_balance(context.source_token, self.owner, amount) ensure_allowance( w3=context.source_w3, diff --git a/derive_client/_bridge/transaction.py b/derive_client/_bridge/transaction.py index 10a78fcf..cb899c1c 100644 --- a/derive_client/_bridge/transaction.py +++ b/derive_client/_bridge/transaction.py @@ -4,25 +4,16 @@ from web3 import Web3 from web3.contract import Contract -from derive_client.constants import DEFAULT_GAS_FUNDING_AMOUNT, DEPOSIT_GAS_LIMIT, MSG_GAS_LIMIT +from derive_client.constants import DEFAULT_GAS_FUNDING_AMOUNT, MSG_GAS_LIMIT from derive_client.data_types import Address, ChainID, TxStatus -from derive_client.exceptions import InsufficientGas +from derive_client.exceptions import InsufficientTokenBalance from derive_client.utils import build_standard_transaction, estimate_fees, exp_backoff_retry, send_and_confirm_tx -def _check_gas_balance(w3: Web3, account: Address, gas_limit=DEPOSIT_GAS_LIMIT): - """Check whether the account has sufficient gas balance.""" - balance = w3.eth.get_balance(account) - if balance < gas_limit: - raise InsufficientGas( - f"Insufficient balance for gas: {gas_limit} < {balance} ({(balance / gas_limit * 100):.2f}%)" - ) - - def ensure_balance(token_contract: Contract, owner: Address, amount: int): balance = token_contract.functions.balanceOf(owner).call() if amount > balance: - raise ValueError(f"Not enough tokens to withdraw: {amount} < {balance} ({(balance / amount * 100):.2f}%)") + raise InsufficientTokenBalance(f"Not enough tokens to withdraw: {amount} < {balance} ({(balance / amount * 100):.2f}%)") def ensure_allowance( diff --git a/derive_client/constants.py b/derive_client/constants.py index 7ec43ef2..02c3f564 100644 --- a/derive_client/constants.py +++ b/derive_client/constants.py @@ -113,6 +113,7 @@ class EnvConfig(BaseModel, frozen=True): DEFAULT_REFERER = "0x9135BA0f495244dc0A5F029b25CDE95157Db89AD" GAS_FEE_BUFFER = 1.1 # buffer multiplier to pad maxFeePerGas +GAS_LIMIT_BUFFER = 1.1 # buffer multiplier to pad gas limit MSG_GAS_LIMIT = 200_000 DEPOSIT_GAS_LIMIT = 420_000 PAYLOAD_SIZE = 161 diff --git a/derive_client/exceptions.py b/derive_client/exceptions.py index 7cfc0873..a31a597b 100644 --- a/derive_client/exceptions.py +++ b/derive_client/exceptions.py @@ -53,8 +53,12 @@ class NoAvailableRPC(Exception): """Raised when all configured RPC endpoints are temporarily unavailable due to backoff or failures.""" -class InsufficientGas(Exception): - """Raised when a minimum gas requirement is not met.""" +class InsufficientNativeBalance(Exception): + """Raised when the native currency balance is insufficient for gas and/or value transfer.""" + + +class InsufficientTokenBalance(Exception): + """Raised when the token balance is insufficient for the requested operation.""" class BridgePrimarySignerRequiredError(Exception): diff --git a/derive_client/utils/w3.py b/derive_client/utils/w3.py index 9a60e230..51aa428e 100644 --- a/derive_client/utils/w3.py +++ b/derive_client/utils/w3.py @@ -17,9 +17,9 @@ from web3.datastructures import AttributeDict from web3.providers.rpc import HTTPProvider -from derive_client.constants import ABI_DATA_DIR, DEFAULT_RPC_ENDPOINTS, GAS_FEE_BUFFER +from derive_client.constants import ABI_DATA_DIR, DEFAULT_RPC_ENDPOINTS, GAS_FEE_BUFFER, GAS_LIMIT_BUFFER from derive_client.data_types import ChainID, RPCEndpoints, TxResult, TxStatus -from derive_client.exceptions import NoAvailableRPC, TxSubmissionError +from derive_client.exceptions import NoAvailableRPC, TxSubmissionError, InsufficientNativeBalance from derive_client.utils.logger import get_logger from derive_client.utils.retry import exp_backoff_retry @@ -178,7 +178,7 @@ def simulate_tx(w3: Web3, tx: dict, account: Account) -> dict: total_cost = max_gas_cost + value if not balance >= total_cost: ratio = balance / total_cost * 100 - raise ValueError(f"Insufficient gas balance, have {balance}, need {total_cost}: ({ratio:.2f})") + raise InsufficientNativeBalance(f"available: {balance} < required: {total_cost} ({ratio:.2f}%)") w3.eth.call(tx) return tx @@ -211,9 +211,7 @@ def build_standard_transaction( } ) - tx["gas"] = w3.eth.estimate_gas(tx) - return tx - + tx["gas"] = int(w3.eth.estimate_gas(tx) * GAS_LIMIT_BUFFER) return simulate_tx(w3, tx, account) From a184cf8cc5eadf5f81e1054b575bf5064e0425e3 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Fri, 8 Aug 2025 16:52:17 +0200 Subject: [PATCH 011/101] refactor: rename to ensure_token_balance and ensure_token_allowance --- derive_client/_bridge/client.py | 16 ++++++++-------- derive_client/_bridge/transaction.py | 8 ++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index 6806d295..1b2c0d80 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -17,8 +17,8 @@ from web3.datastructures import AttributeDict from derive_client._bridge.transaction import ( - ensure_allowance, - ensure_balance, + ensure_token_allowance, + ensure_token_balance, prepare_mainnet_to_derive_gas_tx, ) from derive_client.constants import ( @@ -258,8 +258,8 @@ def deposit(self, amount: int, currency: Currency) -> BridgeTxResult: target_from_block = context.target_w3.eth.block_number spender = token_data.Vault if token_data.isNewBridge else self.deposit_helper.address - ensure_balance(context.source_token, self.owner, amount) - ensure_allowance( + ensure_token_balance(context.source_token, self.owner, amount) + ensure_token_allowance( w3=context.source_w3, token_contract=context.source_token, owner=self.owner, @@ -299,7 +299,7 @@ def withdraw_with_wrapper(self, amount: int, currency: Currency) -> BridgeTxResu context = self._make_bridge_context("withdraw", bridge_type=BridgeType.SOCKET, currency=currency) target_from_block = context.target_w3.eth.block_number - ensure_balance(context.source_token, self.wallet, amount) + ensure_token_balance(context.source_token, self.wallet, amount) self._check_bridge_funds(token_data, connector, amount) @@ -357,8 +357,8 @@ def deposit_drv(self, amount: int, currency: Currency) -> BridgeTxResult: target_from_block = context.target_w3.eth.block_number # check allowance, if needed approve - ensure_balance(context.source_token, self.owner, amount) - ensure_allowance( + ensure_token_balance(context.source_token, self.owner, amount) + ensure_token_allowance( w3=context.source_w3, token_contract=context.source_token, owner=self.owner, @@ -417,7 +417,7 @@ def withdraw_drv(self, amount: int, currency: Currency) -> BridgeTxResult: abi = json.loads(LYRA_OFT_WITHDRAW_WRAPPER_ABI_PATH.read_text()) withdraw_wrapper = get_contract(context.source_w3, LYRA_OFT_WITHDRAW_WRAPPER_ADDRESS, abi=abi) - ensure_balance(context.source_token, self.wallet, amount) + ensure_token_balance(context.source_token, self.wallet, amount) destEID = LayerZeroChainIDv2[context.target_chain.name] fee = withdraw_wrapper.functions.getFeeInToken(context.source_token.address, amount, destEID).call() diff --git a/derive_client/_bridge/transaction.py b/derive_client/_bridge/transaction.py index cb899c1c..b20b1a08 100644 --- a/derive_client/_bridge/transaction.py +++ b/derive_client/_bridge/transaction.py @@ -10,13 +10,13 @@ from derive_client.utils import build_standard_transaction, estimate_fees, exp_backoff_retry, send_and_confirm_tx -def ensure_balance(token_contract: Contract, owner: Address, amount: int): +def ensure_token_balance(token_contract: Contract, owner: Address, amount: int): balance = token_contract.functions.balanceOf(owner).call() if amount > balance: raise InsufficientTokenBalance(f"Not enough tokens to withdraw: {amount} < {balance} ({(balance / amount * 100):.2f}%)") -def ensure_allowance( +def ensure_token_allowance( w3: Web3, token_contract: Contract, owner: Address, @@ -28,7 +28,7 @@ def ensure_allowance( allowance = token_contract.functions.allowance(owner, spender).call() if amount > allowance: logger.info(f"Increasing allowance from {allowance} to {amount}") - increase_allowance( + _increase_token_allowance( w3=w3, from_account=Account.from_key(private_key), erc20_contract=token_contract, @@ -39,7 +39,7 @@ def ensure_allowance( ) -def increase_allowance( +def _increase_token_allowance( w3: Web3, from_account: Account, erc20_contract: Contract, From 637713514682531d5cf8c054192f963b56ce121b Mon Sep 17 00:00:00 2001 From: zarathustra Date: Fri, 8 Aug 2025 17:34:09 +0200 Subject: [PATCH 012/101] refactor: massively simplify Derive gas funding --- derive_client/_bridge/client.py | 81 +++++++++++++--------------- derive_client/_bridge/transaction.py | 61 ++------------------- derive_client/exceptions.py | 8 +++ derive_client/utils/w3.py | 2 +- 4 files changed, 52 insertions(+), 100 deletions(-) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index 1b2c0d80..1883b7fe 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -19,7 +19,6 @@ from derive_client._bridge.transaction import ( ensure_token_allowance, ensure_token_balance, - prepare_mainnet_to_derive_gas_tx, ) from derive_client.constants import ( CONFIGS, @@ -64,7 +63,9 @@ BridgeEventParseError, BridgePrimarySignerRequiredError, BridgeRouteError, - InsufficientGas, + InsufficientNativeBalance, + EthGasFundingPending, + DeriveFundingFailed, ) from derive_client.utils import ( build_standard_transaction, @@ -76,7 +77,6 @@ wait_for_event, wait_for_tx_receipt, ) -from derive_client.utils.w3 import simulate_tx def _load_vault_contract(w3: Web3, token_data: NonMintableTokenData) -> Contract: @@ -322,13 +322,13 @@ def withdraw_with_wrapper(self, amount: int, currency: Currency) -> BridgeTxResu func=[approve_data, bridge_data], ) - tx = build_standard_transaction(func=func, account=self.account, w3=context.source_w3, value=0) - self._ensure_derive_eth_balance(tx) - simulate_tx( - w3=context.source_w3, - tx=tx, - account=self.account, - ) + try: + tx = build_standard_transaction(func=func, account=self.account, w3=context.source_w3, value=0) + except InsufficientNativeBalance: + self._ensure_derive_eth_balance(tx) + self.logger.info("Balance top-up triggered; funds are pending. Cannot proceed with tx now.") + raise EthGasFundingPending("Awaiting ETH deposit for gas.") + source_tx = send_and_confirm_tx( w3=context.source_w3, tx=tx, @@ -439,13 +439,12 @@ def withdraw_drv(self, amount: int, currency: Currency) -> BridgeTxResult: func=[approve_data, bridge_data], ) - tx = build_standard_transaction(func=func, account=self.account, w3=context.source_w3, value=0) - self._ensure_derive_eth_balance(tx) - simulate_tx( - w3=context.source_w3, - tx=tx, - account=self.account, - ) + try: + tx = build_standard_transaction(func=func, account=self.account, w3=context.source_w3, value=0) + except InsufficientNativeBalance: + self._ensure_derive_eth_balance(tx) + self.logger.info("Balance top-up triggered; funds are pending. Cannot proceed with tx now.") + raise EthGasFundingPending("Awaiting ETH deposit for gas.") source_tx = send_and_confirm_tx( w3=context.source_w3, @@ -561,17 +560,7 @@ def poll_bridge_progress(self, tx_result: BridgeTxResult) -> BridgeTxResult: def _ensure_derive_eth_balance(self, tx: dict[str, str]): """Ensure that the Derive EOA wallet has sufficient ETH balance for gas.""" - balance_of_owner = self.derive_w3.eth.get_balance(self.owner) - required_gas = tx['maxFeePerGas'] * tx['gas'] - if balance_of_owner < required_gas + DEFAULT_GAS_FUNDING_AMOUNT: - self.logger.info(f"Funding Derive EOA wallet with {DEFAULT_GAS_FUNDING_AMOUNT} ETH") - self.bridge_mainnet_eth_to_derive(DEFAULT_GAS_FUNDING_AMOUNT) - - def bridge_mainnet_eth_to_derive(self, amount: int) -> TxResult: - """ - Prepares, signs, and sends a transaction to bridge ETH from mainnet to Derive. - This is the "socket superbridge" method; not required when using the withdraw wrapper. - """ + self.logger.info(f"Funding Derive EOA wallet with {DEFAULT_GAS_FUNDING_AMOUNT} ETH") w3 = get_w3_connection(ChainID.ETH, logger=self.logger) @@ -579,23 +568,29 @@ def bridge_mainnet_eth_to_derive(self, amount: int) -> TxResult: bridge_abi = json.loads(L1_STANDARD_BRIDGE_ABI_PATH.read_text()) proxy_contract = get_contract(w3=w3, address=address, abi=bridge_abi) - tx = prepare_mainnet_to_derive_gas_tx(w3=w3, account=self.account, amount=amount, proxy_contract=proxy_contract) - tx['gas'] = w3.eth.estimate_gas(tx) - tx = simulate_tx( - w3=w3, - tx=tx, - account=self.account, + func = proxy_contract.functions.bridgeETH( + MSG_GAS_LIMIT, # _minGasLimit, e.g. Optimism + b"", # _extraData ) - require_gas = tx['maxFeePerGas'] * tx['gas'] - current_balance = w3.eth.get_balance(self.account.address) - if not current_balance >= (amount + require_gas) * 1.1: - raise InsufficientGas( - f"Insufficient ETH balance for bridging amount {amount} + gas {require_gas}. Balance: {current_balance}" + + tx = build_standard_transaction(func=func, account=self.account, w3=w3, value=DEFAULT_GAS_FUNDING_AMOUNT) + try: + tx_result = send_and_confirm_tx( + w3=w3, tx=tx, private_key=self.private_key, action="bridgeETH()", logger=self.logger ) - tx_result = send_and_confirm_tx( - w3=w3, tx=tx, private_key=self.private_key, action="bridgeETH()", logger=self.logger - ) - return tx_result + except InsufficientNativeBalance: + self.logger.warning("Insufficient native balance for bridging ETH to Derive.") + raise + + match tx_result.status: + case TxStatus.SUCCESS: + self.logger.info(f"Funding ttransactionx mined successfully: {tx_result}") + case TxStatus.PENDING: + self.logger.warning(f"Funding transaction is still pending: {tx_result}") + case TxStatus.FAILED: + msg = f"Funding transaction failed: {tx_result}" + self.logger.error(msg) + raise DeriveFundingFailed(msg) def _prepare_new_style_deposit(self, token_data: NonMintableTokenData, amount: int) -> dict: vault_contract = _load_vault_contract(w3=self.remote_w3, token_data=token_data) diff --git a/derive_client/_bridge/transaction.py b/derive_client/_bridge/transaction.py index b20b1a08..e0eb6e31 100644 --- a/derive_client/_bridge/transaction.py +++ b/derive_client/_bridge/transaction.py @@ -4,16 +4,17 @@ from web3 import Web3 from web3.contract import Contract -from derive_client.constants import DEFAULT_GAS_FUNDING_AMOUNT, MSG_GAS_LIMIT -from derive_client.data_types import Address, ChainID, TxStatus +from derive_client.data_types import Address, TxStatus from derive_client.exceptions import InsufficientTokenBalance -from derive_client.utils import build_standard_transaction, estimate_fees, exp_backoff_retry, send_and_confirm_tx +from derive_client.utils import build_standard_transaction, send_and_confirm_tx def ensure_token_balance(token_contract: Contract, owner: Address, amount: int): balance = token_contract.functions.balanceOf(owner).call() if amount > balance: - raise InsufficientTokenBalance(f"Not enough tokens to withdraw: {amount} < {balance} ({(balance / amount * 100):.2f}%)") + raise InsufficientTokenBalance( + f"Not enough tokens to withdraw: {amount} < {balance} ({(balance / amount * 100):.2f}%)" + ) def ensure_token_allowance( @@ -53,55 +54,3 @@ def _increase_token_allowance( tx_result = send_and_confirm_tx(w3=w3, tx=tx, private_key=private_key, action="approve()", logger=logger) if tx_result.status != TxStatus.SUCCESS: raise RuntimeError("approve() failed") - - -def prepare_mainnet_to_derive_gas_tx( - w3: Web3, - account: Account, - proxy_contract: Contract, - amount: int = DEFAULT_GAS_FUNDING_AMOUNT, -) -> dict: - """ - Prepares a bridging transaction to move ETH from Ethereum mainnet to Derive. - This function uses fee estimation and simulates the tx. - """ - - # This bridges ETH from EOA -> EOA, *not* to the smart contract funding wallet. - # If the Derive-side recipient must be a smart contract, this must be changed. - - if not w3.eth.chain_id == ChainID.ETH: - raise ValueError(f"Connected to chain ID {w3.eth.chain_id}, but expected Ethereum mainnet ({ChainID.ETH}).") - - balance = w3.eth.get_balance(account.address) - nonce = w3.eth.get_transaction_count(account.address) - - @exp_backoff_retry - def simulate_tx(): - fee_estimations = estimate_fees(w3, blocks=10, percentiles=[99]) - max_fee = fee_estimations[0]["maxFeePerGas"] - priority_fee = fee_estimations[0]["maxPriorityFeePerGas"] - - tx = proxy_contract.functions.bridgeETH( - MSG_GAS_LIMIT, # _minGasLimit # Optimism - b"", # _extraData - ).build_transaction( - { - "from": account.address, - "value": amount, - "nonce": nonce, - "maxFeePerGas": max_fee, - "maxPriorityFeePerGas": priority_fee, - "chainId": ChainID.ETH, - } - ) - estimated_gas = w3.eth.estimate_gas(tx) - tx["gas"] = estimated_gas - required = estimated_gas * max_fee + amount - if balance < required: - raise RuntimeError( - f"Insufficient funds: have {balance}, need {required} ({(balance / required * 100):.2f}%" - ) - w3.eth.call(tx) - return tx - - return simulate_tx() diff --git a/derive_client/exceptions.py b/derive_client/exceptions.py index a31a597b..19ff58f2 100644 --- a/derive_client/exceptions.py +++ b/derive_client/exceptions.py @@ -63,3 +63,11 @@ class InsufficientTokenBalance(Exception): class BridgePrimarySignerRequiredError(Exception): """Raised when bridging is attempted with a secondary session-key signer.""" + + +class EthGasFundingPending(Exception): + """Raised after we detect lack of ETH on Derive to pay for gas.""" + + +class DeriveFundingFailed(Exception): + """Raised when funding the Derive wallet with gas fails.""" diff --git a/derive_client/utils/w3.py b/derive_client/utils/w3.py index 51aa428e..3e3460fb 100644 --- a/derive_client/utils/w3.py +++ b/derive_client/utils/w3.py @@ -19,7 +19,7 @@ from derive_client.constants import ABI_DATA_DIR, DEFAULT_RPC_ENDPOINTS, GAS_FEE_BUFFER, GAS_LIMIT_BUFFER from derive_client.data_types import ChainID, RPCEndpoints, TxResult, TxStatus -from derive_client.exceptions import NoAvailableRPC, TxSubmissionError, InsufficientNativeBalance +from derive_client.exceptions import InsufficientNativeBalance, NoAvailableRPC, TxSubmissionError from derive_client.utils.logger import get_logger from derive_client.utils.retry import exp_backoff_retry From fbfe9b1e39cb596fdc7061a7e822c74ed514a954 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Fri, 8 Aug 2025 17:49:26 +0200 Subject: [PATCH 013/101] chore: make fmt lint --- derive_client/_bridge/client.py | 19 +++++++------------ derive_client/clients/base_client.py | 16 +++++++++++++--- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index 1883b7fe..2c89b2f6 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -16,10 +16,7 @@ from web3.contract import Contract from web3.datastructures import AttributeDict -from derive_client._bridge.transaction import ( - ensure_token_allowance, - ensure_token_balance, -) +from derive_client._bridge.transaction import ensure_token_allowance, ensure_token_balance from derive_client.constants import ( CONFIGS, CONTROLLER_ABI_PATH, @@ -63,9 +60,9 @@ BridgeEventParseError, BridgePrimarySignerRequiredError, BridgeRouteError, - InsufficientNativeBalance, - EthGasFundingPending, DeriveFundingFailed, + EthGasFundingPending, + InsufficientNativeBalance, ) from derive_client.utils import ( build_standard_transaction, @@ -535,9 +532,8 @@ def poll_bridge_progress(self, tx_result: BridgeTxResult) -> BridgeTxResult: with suppress(TimeoutError): # 1. TimeoutError as exception during source_tx.tx_receipt if not tx_result.source_tx.tx_receipt: - self.logger.info( - f"⏳ Checking source chain [{tx_result.source_chain.name}] tx receipt for {tx_result.source_tx.tx_hash}" - ) + msg = "⏳ Checking source chain [%s] tx receipt for %s" + self.logger.info(msg, tx_result.source_chain.name, tx_result.source_tx.tx_hash) tx_result.source_tx.tx_receipt = wait_for_tx_receipt( w3=context.source_w3, tx_hash=tx_result.source_tx.tx_hash ) @@ -549,9 +545,8 @@ def poll_bridge_progress(self, tx_result: BridgeTxResult) -> BridgeTxResult: # 3. TimeoutError waiting for target_tx.tx_receipt if not tx_result.target_tx.tx_receipt: - self.logger.info( - f"⏳ Checking target chain [{tx_result.target_chain.name}] tx receipt for {tx_result.target_tx.tx_hash}" - ) + msg = "⏳ Checking target chain [%s] tx receipt for %s" + self.logger.info(msg, tx_result.target_chain.name, tx_result.target_tx.tx_hash) tx_result.target_tx.tx_receipt = wait_for_tx_receipt( w3=context.target_w3, tx_hash=tx_result.target_tx.tx_hash ) diff --git a/derive_client/clients/base_client.py b/derive_client/clients/base_client.py index 16c0ba97..6eee38d2 100644 --- a/derive_client/clients/base_client.py +++ b/derive_client/clients/base_client.py @@ -21,8 +21,8 @@ from derive_action_signing.signed_action import SignedAction from derive_action_signing.utils import MAX_INT_32, get_action_nonce, sign_rest_auth_header, utc_now_ms from pydantic import validate_call -from web3 import Web3 from returns.result import Result, safe +from web3 import Web3 from derive_client._bridge import BridgeClient from derive_client.constants import CONFIGS, DEFAULT_REFERER, PUBLIC_HEADERS, TOKEN_DECIMALS @@ -137,7 +137,12 @@ def create_account(self, wallet): @safe @validate_call - def deposit_to_derive(self, chain_id: ChainID, currency: Currency, amount: float) -> Result[BridgeTxResult, Exception]: + def deposit_to_derive( + self, + chain_id: ChainID, + currency: Currency, + amount: float, + ) -> Result[BridgeTxResult, Exception]: """ Submit a deposit into the Derive chain funding contract and return its initial BridgeTxResult without waiting for completion. @@ -158,7 +163,12 @@ def deposit_to_derive(self, chain_id: ChainID, currency: Currency, amount: float @safe @validate_call - def withdraw_from_derive(self, chain_id: ChainID, currency: Currency, amount: float) -> Result[BridgeTxResult, Exception]: + def withdraw_from_derive( + self, + chain_id: ChainID, + currency: Currency, + amount: float, + ) -> Result[BridgeTxResult, Exception]: """ Submit a withdrawal from the Derive chain funding contract and return its initial BridgeTxResult without waiting for completion. From d7e72e197236955e21b334b88a9b5eedd078e9aa Mon Sep 17 00:00:00 2001 From: zarathustra Date: Fri, 8 Aug 2025 17:58:44 +0200 Subject: [PATCH 014/101] fix: error handling in cli withdraw and deposit commands --- derive_client/cli.py | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/derive_client/cli.py b/derive_client/cli.py index d4d3ab72..9256e1bd 100644 --- a/derive_client/cli.py +++ b/derive_client/cli.py @@ -9,8 +9,8 @@ import pandas as pd import rich_click as click from dotenv import load_dotenv +from returns.result import Failure, Success from rich import print -from returns.result import Success, Failure from derive_client.analyser import PortfolioAnalyser from derive_client.clients.base_client import BaseClient @@ -159,25 +159,23 @@ def deposit(ctx, chain_id, currency, amount): client: BaseClient = ctx.obj["client"] result = ( - client - .deposit_to_derive(chain_id=chain_id, currency=currency, amount=amount) + client.deposit_to_derive(chain_id=chain_id, currency=currency, amount=amount) .bind(client.poll_bridge_progress) .alt(lambda exc: click.ClickException(f"Error attempting to deposit: {exc}")) ) match result: case Success(bridge_tx_result): + msg_prefix = f"Bridging {currency.name} from {chain_id.name} to DERIVE" match bridge_tx_result.status: case TxStatus.SUCCESS: - print(f"[bold green]Bridging {currency.name} from {chain_id.name} to DERIVE successful![/bold green]") + print(f"[bold green]{msg_prefix} successful![/bold green]") case TxStatus.FAILED: - print(f"[bold red]Bridging {currency.name} from {chain_id.name} to DERIVE failed.[/bold red]") + print(f"[bold red]{msg_prefix} failed.[/bold red]") case TxStatus.PENDING: - print(f"[yellow]Bridging {currency.name} from {chain_id.name} to DERIVE is pending...[/yellow]") - case _: - raise click.ClickException(f"Exception attempting to deposit:\n{bridge_tx_result}") + print(f"[yellow]{msg_prefix} pending...[/yellow]") case Failure(exc): - raise exc + raise click.ClickException(f"Exception attempting to deposit:\n{exc}") @bridge.command("withdraw") @@ -217,25 +215,23 @@ def withdraw(ctx, chain_id, currency, amount): client: DeriveClient = ctx.obj["client"] result = ( - client - .withdraw_from_derive(chain_id=chain_id, currency=currency, amount=amount) + client.withdraw_from_derive(chain_id=chain_id, currency=currency, amount=amount) .bind(client.poll_bridge_progress) .alt(lambda exc: click.ClickException(f"Error attempting to withdraw: {exc}")) ) match result: case Success(bridge_tx_result): + msg_prefix = f"Bridging {currency.name} from DERIVE to {chain_id.name}" match bridge_tx_result.status: case TxStatus.SUCCESS: - print(f"[bold green]Bridging {currency.name} from DERIVE to {chain_id.name} successful![/bold green]") + print(f"[bold green]{msg_prefix} successful![/bold green]") case TxStatus.FAILED: - print(f"[bold red]Bridging {currency.name} from DERIVE to {chain_id.name} failed.[/bold red]") + print(f"[bold red]{msg_prefix} failed.[/bold red]") case TxStatus.PENDING: - print(f"[yellow]Bridging {currency.name} from DERIVE to {chain_id.name} is pending...[/yellow]") - case _: - raise click.ClickException(f"Exception attempting to withdraw:\n{bridge_tx_result}") + print(f"[yellow]{msg_prefix} is pending...[/yellow]") case Failure(exc): - raise exc + raise click.ClickException(f"Exception attempting to withdraw:\n{exc}") @cli.group("instruments") From 2b247d43c0888ec1aab901d512e92f6931e992fd Mon Sep 17 00:00:00 2001 From: zarathustra Date: Fri, 8 Aug 2025 18:14:28 +0200 Subject: [PATCH 015/101] feat: DrvWithdrawAmountBelowFee exception --- derive_client/_bridge/client.py | 3 ++- derive_client/exceptions.py | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index 2c89b2f6..a4c2890e 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -61,6 +61,7 @@ BridgePrimarySignerRequiredError, BridgeRouteError, DeriveFundingFailed, + DrvWithdrawAmountBelowFee, EthGasFundingPending, InsufficientNativeBalance, ) @@ -419,7 +420,7 @@ def withdraw_drv(self, amount: int, currency: Currency) -> BridgeTxResult: destEID = LayerZeroChainIDv2[context.target_chain.name] fee = withdraw_wrapper.functions.getFeeInToken(context.source_token.address, amount, destEID).call() if amount < fee: - raise ValueError(f"Withdraw amount < fee: {amount} < {fee} ({(fee / amount * 100):.2f}%)") + raise DrvWithdrawAmountBelowFee(f"Withdraw amount < fee: {amount} < {fee} ({(fee / amount * 100):.2f}%)") kwargs = { "token": context.source_token.address, diff --git a/derive_client/exceptions.py b/derive_client/exceptions.py index 19ff58f2..c40a58c0 100644 --- a/derive_client/exceptions.py +++ b/derive_client/exceptions.py @@ -71,3 +71,7 @@ class EthGasFundingPending(Exception): class DeriveFundingFailed(Exception): """Raised when funding the Derive wallet with gas fails.""" + + +class DrvWithdrawAmountBelowFee(Exception): + """Raised when the DRV withdrawal amount is less than the fee required to withdraw.""" From 4fa51c78d9a834e17237ca9b3c412e759360408b Mon Sep 17 00:00:00 2001 From: zarathustra Date: Sat, 9 Aug 2025 14:36:31 +0200 Subject: [PATCH 016/101] Revert "fix: update withdraw and deposit cli commands" This reverts commit c095c4ac1fe7ddc04d429d6dbccfb5e20873fde0. --- derive_client/cli.py | 59 +++++++++++++++++--------------------------- 1 file changed, 23 insertions(+), 36 deletions(-) diff --git a/derive_client/cli.py b/derive_client/cli.py index 9256e1bd..c69688c8 100644 --- a/derive_client/cli.py +++ b/derive_client/cli.py @@ -9,7 +9,6 @@ import pandas as pd import rich_click as click from dotenv import load_dotenv -from returns.result import Failure, Success from rich import print from derive_client.analyser import PortfolioAnalyser @@ -158,24 +157,18 @@ def deposit(ctx, chain_id, currency, amount): client: BaseClient = ctx.obj["client"] - result = ( - client.deposit_to_derive(chain_id=chain_id, currency=currency, amount=amount) - .bind(client.poll_bridge_progress) - .alt(lambda exc: click.ClickException(f"Error attempting to deposit: {exc}")) - ) + bridge_tx_result = client.deposit_to_derive(chain_id=chain_id, currency=currency, amount=amount) + bridge_tx_result = client.poll_bridge_progress(bridge_tx_result) - match result: - case Success(bridge_tx_result): - msg_prefix = f"Bridging {currency.name} from {chain_id.name} to DERIVE" - match bridge_tx_result.status: - case TxStatus.SUCCESS: - print(f"[bold green]{msg_prefix} successful![/bold green]") - case TxStatus.FAILED: - print(f"[bold red]{msg_prefix} failed.[/bold red]") - case TxStatus.PENDING: - print(f"[yellow]{msg_prefix} pending...[/yellow]") - case Failure(exc): - raise click.ClickException(f"Exception attempting to deposit:\n{exc}") + match bridge_tx_result.status: + case TxStatus.SUCCESS: + print(f"[bold green]Bridging {currency.name} from {chain_id.name} to DERIVE successful![/bold green]") + case TxStatus.FAILED: + print(f"[bold red]Bridging {currency.name} from {chain_id.name} to DERIVE failed.[/bold red]") + case TxStatus.PENDING: + print(f"[yellow]Bridging {currency.name} from {chain_id.name} to DERIVE is pending...[/yellow]") + case _: + raise click.ClickException(f"Exception attempting to deposit:\n{bridge_tx_result}") @bridge.command("withdraw") @@ -214,24 +207,18 @@ def withdraw(ctx, chain_id, currency, amount): client: DeriveClient = ctx.obj["client"] - result = ( - client.withdraw_from_derive(chain_id=chain_id, currency=currency, amount=amount) - .bind(client.poll_bridge_progress) - .alt(lambda exc: click.ClickException(f"Error attempting to withdraw: {exc}")) - ) - - match result: - case Success(bridge_tx_result): - msg_prefix = f"Bridging {currency.name} from DERIVE to {chain_id.name}" - match bridge_tx_result.status: - case TxStatus.SUCCESS: - print(f"[bold green]{msg_prefix} successful![/bold green]") - case TxStatus.FAILED: - print(f"[bold red]{msg_prefix} failed.[/bold red]") - case TxStatus.PENDING: - print(f"[yellow]{msg_prefix} is pending...[/yellow]") - case Failure(exc): - raise click.ClickException(f"Exception attempting to withdraw:\n{exc}") + bridge_tx_result = client.withdraw_from_derive(chain_id=chain_id, currency=currency, amount=amount) + bridge_tx_result = client.poll_bridge_progress(bridge_tx_result) + + match bridge_tx_result.status: + case TxStatus.SUCCESS: + print(f"[bold green]Bridging {currency.name} from DERIVE to {chain_id.name} successful![/bold green]") + case TxStatus.FAILED: + print(f"[bold red]Bridging {currency.name} from DERIVE to {chain_id.name} failed.[/bold red]") + case TxStatus.PENDING: + print(f"[yellow]Bridging {currency.name} from DERIVE to {chain_id.name} is pending...[/yellow]") + case _: + raise click.ClickException(f"Exception attempting to withdraw:\n{bridge_tx_result}") @cli.group("instruments") From 5c01e3cf62d8eb638d042d5438a061ee9b11bd3d Mon Sep 17 00:00:00 2001 From: zarathustra Date: Sat, 9 Aug 2025 16:13:29 +0200 Subject: [PATCH 017/101] feat: unwrap_or_raise --- derive_client/utils/unwrap.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 derive_client/utils/unwrap.py diff --git a/derive_client/utils/unwrap.py b/derive_client/utils/unwrap.py new file mode 100644 index 00000000..17a93f3f --- /dev/null +++ b/derive_client/utils/unwrap.py @@ -0,0 +1,17 @@ +from typing import TypeVar + +from returns.result import Failure, Result, Success + +T = TypeVar("T") + + +def unwrap_or_raise(result: Result[T, Exception]) -> T: + """Convert a returns.Result into a normal Python value or raise the underlying exception.""" + + match result: + case Success(): + return result.unwrap() + case Failure(): + raise result.failure() + case _: + raise RuntimeError("unwrap_or_raise received a non-Result value") From cf251f58dd97c3607318be497d8a535354bd053d Mon Sep 17 00:00:00 2001 From: zarathustra Date: Sat, 9 Aug 2025 16:15:15 +0200 Subject: [PATCH 018/101] feat: Result-returning and unwrap-or-raise bridge methods --- derive_client/clients/base_client.py | 79 ++++++++++++++++++++++++++-- derive_client/utils/__init__.py | 2 + 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/derive_client/clients/base_client.py b/derive_client/clients/base_client.py index 6eee38d2..220bf66b 100644 --- a/derive_client/clients/base_client.py +++ b/derive_client/clients/base_client.py @@ -54,7 +54,7 @@ ) from derive_client.endpoints import RestAPI from derive_client.exceptions import DeriveJSONRPCException -from derive_client.utils import get_logger, wait_until +from derive_client.utils import get_logger, unwrap_or_raise, wait_until def _is_final_tx(res: DeriveTxResult) -> bool: @@ -137,7 +137,7 @@ def create_account(self, wallet): @safe @validate_call - def deposit_to_derive( + def deposit_to_derive_result( self, chain_id: ChainID, currency: Currency, @@ -151,6 +151,9 @@ def deposit_to_derive( chain_id (ChainID): The chain you are bridging FROM. currency (Currency): The asset being bridged. amount (float): amount to deposit, in human units (will be scaled to Wei). + Returns: + Result[BridgeTxResult, Exception]: A Result object containing either the BridgeTxResult on success, + or an Exception on failure. """ amount = int(amount * 10 ** TOKEN_DECIMALS[UnderlyingCurrency[currency.name.upper()]]) @@ -161,9 +164,32 @@ def deposit_to_derive( return client.deposit(amount=amount, currency=currency) + def deposit_to_derive( + self, + chain_id: ChainID, + currency: Currency, + amount: float, + ) -> BridgeTxResult: + """ + Submit a deposit into the Derive chain funding contract and return its initial BridgeTxResult + without waiting for completion. + + Parameters: + chain_id (ChainID): The chain you are bridging FROM. + currency (Currency): The asset being bridged. + amount (float): amount to deposit, in human units (will be scaled to Wei). + Raises: + Exception: If the deposit fails. + Returns: + BridgeTxResult: The result of the deposit operation. + """ + + result = self.deposit_to_derive_result(chain_id=chain_id, currency=currency, amount=amount) + return unwrap_or_raise(result) + @safe @validate_call - def withdraw_from_derive( + def withdraw_from_derive_result( self, chain_id: ChainID, currency: Currency, @@ -177,6 +203,9 @@ def withdraw_from_derive( chain_id (ChainID): The chain you are bridging TO. currency (Currency): The asset being bridged. amount (float): amount to withdraw, in human units (will be scaled to Wei). + Returns: + Result[BridgeTxResult, Exception]: A Result object containing either the BridgeTxResult on success, + or an Exception on failure. """ amount = int(amount * 10 ** TOKEN_DECIMALS[UnderlyingCurrency[currency.name.upper()]]) @@ -187,21 +216,63 @@ def withdraw_from_derive( return client.withdraw_with_wrapper(amount=amount, currency=currency) + def withdraw_from_derive( + self, + chain_id: ChainID, + currency: Currency, + amount: float, + ) -> BridgeTxResult: + """ + Submit a withdrawal from the Derive chain funding contract and return its initial BridgeTxResult + without waiting for completion. + + Parameters: + chain_id (ChainID): The chain you are bridging TO. + currency (Currency): The asset being bridged. + amount (float): amount to withdraw, in human units (will be scaled to Wei). + Raises: + Exception: If the withdrawal fails. + Returns: + BridgeTxResult: The result of the withdrawal operation. + """ + + result = self.withdraw_from_derive_result(chain_id=chain_id, currency=currency, amount=amount) + return unwrap_or_raise(result) + @safe @validate_call - def poll_bridge_progress(self, tx_result: BridgeTxResult) -> Result[BridgeTxResult, Exception]: + def poll_bridge_progress_result(self, tx_result: BridgeTxResult) -> Result[BridgeTxResult, Exception]: """ Given a pending BridgeTxResult, return a new BridgeTxResult with updated status. Raises AlreadyFinalizedError if tx_result is not in PENDING status. Parameters: tx_result (BridgeTxResult): the result to refresh. + Returns: + Result[BridgeTxResult, Exception]: A Result object containing either the BridgeTxResult on success, + or an Exception on failure. """ chain_id = tx_result.source_chain if tx_result.source_chain != ChainID.DERIVE else tx_result.target_chain client = BridgeClient(self.env, chain_id, account=self.signer, wallet=self.wallet, logger=self.logger) return client.poll_bridge_progress(tx_result=tx_result) + def poll_bridge_progress(self, tx_result: BridgeTxResult) -> BridgeTxResult: + """ + Given a pending BridgeTxResult, return a new BridgeTxResult with updated status. + Raises AlreadyFinalizedError if tx_result is not in PENDING status. + + Parameters: + tx_result (BridgeTxResult): the result to refresh. + Raises: + Exception: If the polling fails. + Returns: + BridgeTxResult: The result of the polling operation. + """ + + result = self.poll_bridge_progress_result(tx_result=tx_result) + return unwrap_or_raise(result) + def fetch_instruments( self, expired=False, diff --git a/derive_client/utils/__init__.py b/derive_client/utils/__init__.py index d631367b..c71ca835 100644 --- a/derive_client/utils/__init__.py +++ b/derive_client/utils/__init__.py @@ -4,6 +4,7 @@ from .logger import get_logger from .prod_addresses import get_prod_derive_addresses from .retry import exp_backoff_retry, get_retry_session, wait_until +from .unwrap import unwrap_or_raise from .w3 import ( build_standard_transaction, estimate_fees, @@ -40,4 +41,5 @@ "build_standard_transaction", "iter_events", "wait_for_event", + "unwrap_or_raise", ] From d9681429dda2943aee4991a3846df9cc39628331 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Sat, 9 Aug 2025 22:41:33 +0200 Subject: [PATCH 019/101] chore: poetry lock --- poetry.lock | 166 +++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 119 insertions(+), 47 deletions(-) diff --git a/poetry.lock b/poetry.lock index 497a8c38..e43f54eb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -6,6 +6,7 @@ version = "2.6.1" description = "Happy Eyeballs for asyncio" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8"}, {file = "aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558"}, @@ -17,6 +18,7 @@ version = "3.12.13" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "aiohttp-3.12.13-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5421af8f22a98f640261ee48aae3a37f0c41371e99412d55eaf2f8a46d5dad29"}, {file = "aiohttp-3.12.13-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fcda86f6cb318ba36ed8f1396a6a4a3fd8f856f84d426584392083d10da4de0"}, @@ -117,7 +119,19 @@ propcache = ">=0.2.0" yarl = ">=1.17.0,<2.0" [package.extras] -speedups = ["Brotli", "aiodns (>=3.3.0)", "brotlicffi"] +speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns (>=3.3.0)", "brotlicffi ; platform_python_implementation != \"CPython\""] + +[[package]] +name = "aiolimiter" +version = "1.2.1" +description = "asyncio rate limiter, a leaky bucket implementation" +optional = false +python-versions = "<4.0,>=3.8" +groups = ["main"] +files = [ + {file = "aiolimiter-1.2.1-py3-none-any.whl", hash = "sha256:d3f249e9059a20badcb56b61601a83556133655c11d1eb3dd3e04ff069e5f3c7"}, + {file = "aiolimiter-1.2.1.tar.gz", hash = "sha256:e02a37ea1a855d9e832252a105420ad4d15011505512a1a1d814647451b5cca9"}, +] [[package]] name = "aiosignal" @@ -125,6 +139,7 @@ version = "1.3.2" description = "aiosignal: a list of registered asynchronous callbacks" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5"}, {file = "aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54"}, @@ -139,6 +154,7 @@ version = "0.7.0" description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, @@ -163,6 +179,7 @@ version = "25.3.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3"}, {file = "attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b"}, @@ -182,6 +199,7 @@ version = "3.4.3" description = "efficient arrays of booleans -- C extension" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "bitarray-3.4.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a0c126a6ed1d3cd68cd91c0056cee8edcf6aa57c557b555528fe37375e72ea74"}, {file = "bitarray-3.4.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:690fc6d2b5c5e267f643e3720e8b4203838d3f30439e2070dccfae473b8223c3"}, @@ -325,6 +343,7 @@ version = "24.10.0" description = "The uncompromising code formatter." optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "black-24.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812"}, {file = "black-24.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea"}, @@ -371,6 +390,7 @@ version = "2025.6.15" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" +groups = ["main", "dev"] files = [ {file = "certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057"}, {file = "certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b"}, @@ -382,6 +402,7 @@ version = "3.4.2" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" +groups = ["main", "dev"] files = [ {file = "charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941"}, {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd"}, @@ -483,6 +504,7 @@ version = "2.1.1" description = "Python bindings for C-KZG-4844" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "ckzg-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b9825a1458219e8b4b023012b8ef027ef1f47e903f9541cbca4615f80132730"}, {file = "ckzg-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e2a40a3ba65cca4b52825d26829e6f7eb464aa27a9e9efb6b8b2ce183442c741"}, @@ -592,6 +614,7 @@ version = "0.19.0" description = "Build Nice User Interfaces In The Terminal" optional = false python-versions = "<4.0,>=3.9" +groups = ["dev"] files = [ {file = "cli_ui-0.19.0-py3-none-any.whl", hash = "sha256:1cf1b93328f7377730db29507e10bcb29ccc1427ceef45714b522d1f2055e7cd"}, {file = "cli_ui-0.19.0.tar.gz", hash = "sha256:59cdab0c6a2a6703c61b31cb75a1943076888907f015fffe15c5a8eb41a933aa"}, @@ -608,6 +631,7 @@ version = "8.1.8" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" +groups = ["main", "dev"] files = [ {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, @@ -627,6 +651,7 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {main = "platform_system == \"Windows\""} [[package]] name = "cytoolz" @@ -634,6 +659,8 @@ version = "1.0.1" description = "Cython implementation of Toolz: High performance functional utilities" optional = false python-versions = ">=3.8" +groups = ["main"] +markers = "implementation_name == \"cpython\"" files = [ {file = "cytoolz-1.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cec9af61f71fc3853eb5dca3d42eb07d1f48a4599fa502cbe92adde85f74b042"}, {file = "cytoolz-1.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:140bbd649dbda01e91add7642149a5987a7c3ccc251f2263de894b89f50b6608"}, @@ -749,6 +776,7 @@ version = "0.0.12" description = "Python package to sign on-chain self-custodial requests for orders, transfers, deposits, withdrawals and RFQs." optional = false python-versions = "<4.0,>=3.9" +groups = ["main"] files = [ {file = "derive_action_signing-0.0.12-py3-none-any.whl", hash = "sha256:c7fbee46e8fc6abac9204bec6fee9922d22800f647fee4c44f0e15a72eecd187"}, {file = "derive_action_signing-0.0.12.tar.gz", hash = "sha256:2ede7861234fd677abd05f88d2e0f27e27966753e02735e938a97be173bd277f"}, @@ -768,6 +796,7 @@ version = "0.6.2" description = "Pythonic argument parser, that will make you smile" optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, ] @@ -778,6 +807,7 @@ version = "5.2.0" description = "eth_abi: Python utilities for working with Ethereum ABI definitions, especially encoding and decoding" optional = false python-versions = "<4,>=3.8" +groups = ["main"] files = [ {file = "eth_abi-5.2.0-py3-none-any.whl", hash = "sha256:17abe47560ad753f18054f5b3089fcb588f3e3a092136a416b6c1502cb7e8877"}, {file = "eth_abi-5.2.0.tar.gz", hash = "sha256:178703fa98c07d8eecd5ae569e7e8d159e493ebb6eeb534a8fe973fbc4e40ef0"}, @@ -800,6 +830,7 @@ version = "0.13.7" description = "eth-account: Sign Ethereum transactions and messages with local private keys" optional = false python-versions = "<4,>=3.8" +groups = ["main"] files = [ {file = "eth_account-0.13.7-py3-none-any.whl", hash = "sha256:39727de8c94d004ff61d10da7587509c04d2dc7eac71e04830135300bdfc6d24"}, {file = "eth_account-0.13.7.tar.gz", hash = "sha256:5853ecbcbb22e65411176f121f5f24b8afeeaf13492359d254b16d8b18c77a46"}, @@ -828,6 +859,7 @@ version = "0.7.1" description = "eth-hash: The Ethereum hashing function, keccak256, sometimes (erroneously) called sha3" optional = false python-versions = "<4,>=3.8" +groups = ["main"] files = [ {file = "eth_hash-0.7.1-py3-none-any.whl", hash = "sha256:0fb1add2adf99ef28883fd6228eb447ef519ea72933535ad1a0b28c6f65f868a"}, {file = "eth_hash-0.7.1.tar.gz", hash = "sha256:d2411a403a0b0a62e8247b4117932d900ffb4c8c64b15f92620547ca5ce46be5"}, @@ -849,6 +881,7 @@ version = "0.8.1" description = "eth-keyfile: A library for handling the encrypted keyfiles used to store ethereum private keys" optional = false python-versions = "<4,>=3.8" +groups = ["main"] files = [ {file = "eth_keyfile-0.8.1-py3-none-any.whl", hash = "sha256:65387378b82fe7e86d7cb9f8d98e6d639142661b2f6f490629da09fddbef6d64"}, {file = "eth_keyfile-0.8.1.tar.gz", hash = "sha256:9708bc31f386b52cca0969238ff35b1ac72bd7a7186f2a84b86110d3c973bec1"}, @@ -870,6 +903,7 @@ version = "0.7.0" description = "eth-keys: Common API for Ethereum key operations" optional = false python-versions = "<4,>=3.8" +groups = ["main"] files = [ {file = "eth_keys-0.7.0-py3-none-any.whl", hash = "sha256:b0cdda8ffe8e5ba69c7c5ca33f153828edcace844f67aabd4542d7de38b159cf"}, {file = "eth_keys-0.7.0.tar.gz", hash = "sha256:79d24fd876201df67741de3e3fefb3f4dbcbb6ace66e47e6fe662851a4547814"}, @@ -891,6 +925,7 @@ version = "2.2.0" description = "eth-rlp: RLP definitions for common Ethereum objects in Python" optional = false python-versions = "<4,>=3.8" +groups = ["main"] files = [ {file = "eth_rlp-2.2.0-py3-none-any.whl", hash = "sha256:5692d595a741fbaef1203db6a2fedffbd2506d31455a6ad378c8449ee5985c47"}, {file = "eth_rlp-2.2.0.tar.gz", hash = "sha256:5e4b2eb1b8213e303d6a232dfe35ab8c29e2d3051b86e8d359def80cd21db83d"}, @@ -913,6 +948,7 @@ version = "4.0.0" description = "eth-typing: Common type annotations for ethereum python packages" optional = false python-versions = ">=3.8, <4" +groups = ["main"] files = [ {file = "eth-typing-4.0.0.tar.gz", hash = "sha256:9af0b6beafbc5c2e18daf19da5f5a68315023172c4e79d149e12ad10a3d3f731"}, {file = "eth_typing-4.0.0-py3-none-any.whl", hash = "sha256:7e556bea322b6e8c0a231547b736c258e10ce9eed5ddc254f51031b12af66a16"}, @@ -929,6 +965,7 @@ version = "4.1.1" description = "eth-utils: Common utility functions for python code that interacts with Ethereum" optional = false python-versions = "<4,>=3.8" +groups = ["main"] files = [ {file = "eth_utils-4.1.1-py3-none-any.whl", hash = "sha256:ccbbac68a6d65cb6e294c5bcb6c6a5cec79a241c56dc5d9c345ed788c30f8534"}, {file = "eth_utils-4.1.1.tar.gz", hash = "sha256:71c8d10dec7494aeed20fa7a4d52ec2ce4a2e52fdce80aab4f5c3c19f3648b25"}, @@ -970,6 +1007,7 @@ version = "6.1.0" description = "the modular source code checker: pep8 pyflakes and co" optional = false python-versions = ">=3.8.1" +groups = ["dev"] files = [ {file = "flake8-6.1.0-py2.py3-none-any.whl", hash = "sha256:ffdfce58ea94c6580c77888a86506937f9a1a227dfcd15f245d694ae20a6b6e5"}, {file = "flake8-6.1.0.tar.gz", hash = "sha256:d5b3857f07c030bdb5bf41c7f53799571d75c4491748a3adcd47de929e34cd23"}, @@ -986,6 +1024,7 @@ version = "1.7.0" description = "A list-like structure which implements collections.abc.MutableSequence" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "frozenlist-1.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cc4df77d638aa2ed703b878dd093725b72a824c3c546c076e8fdf276f78ee84a"}, {file = "frozenlist-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:716a9973a2cc963160394f701964fe25012600f3d311f60c790400b00e568b61"}, @@ -1099,6 +1138,7 @@ version = "2.1.0" description = "Copy your docs directly to the gh-pages branch." optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"}, {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"}, @@ -1116,6 +1156,7 @@ version = "1.3.1" description = "hexbytes: Python `bytes` subclass that decodes hex, with a readable console output" optional = false python-versions = "<4,>=3.8" +groups = ["main"] files = [ {file = "hexbytes-1.3.1-py3-none-any.whl", hash = "sha256:da01ff24a1a9a2b1881c4b85f0e9f9b0f51b526b379ffa23832ae7899d29c2c7"}, {file = "hexbytes-1.3.1.tar.gz", hash = "sha256:a657eebebdfe27254336f98d8af6e2236f3f83aed164b87466b6cf6c5f5a4765"}, @@ -1132,6 +1173,7 @@ version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" +groups = ["main", "dev"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -1140,35 +1182,13 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] -[[package]] -name = "importlib-metadata" -version = "8.7.0" -description = "Read metadata from Python packages" -optional = false -python-versions = ">=3.9" -files = [ - {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"}, - {file = "importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"}, -] - -[package.dependencies] -zipp = ">=3.20" - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -perf = ["ipython"] -test = ["flufl.flake8", "importlib_resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] -type = ["pytest-mypy"] - [[package]] name = "iniconfig" version = "2.1.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, @@ -1180,6 +1200,7 @@ version = "5.13.2" description = "A Python utility / library to sort Python imports." optional = false python-versions = ">=3.8.0" +groups = ["dev"] files = [ {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, @@ -1194,6 +1215,7 @@ version = "3.1.6" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, @@ -1211,6 +1233,7 @@ version = "4.24.0" description = "An implementation of JSON Schema validation for Python" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "jsonschema-4.24.0-py3-none-any.whl", hash = "sha256:a462455f19f5faf404a7902952b6f0e3ce868f3ee09a359b05eca6673bd8412d"}, {file = "jsonschema-4.24.0.tar.gz", hash = "sha256:0b4e8069eb12aedfa881333004bccaec24ecef5a8a6a4b6df142b2cc9599d196"}, @@ -1232,6 +1255,7 @@ version = "2025.4.1" description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "jsonschema_specifications-2025.4.1-py3-none-any.whl", hash = "sha256:4653bffbd6584f7de83a67e0d620ef16900b390ddc7939d56684d6c81e33f1af"}, {file = "jsonschema_specifications-2025.4.1.tar.gz", hash = "sha256:630159c9f4dbea161a6a2205c3011cc4f18ff381b189fff48bb39b9bf26ae608"}, @@ -1246,6 +1270,7 @@ version = "1.3.0" description = "An Dict like LRU container." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "lru-dict-1.3.0.tar.gz", hash = "sha256:54fd1966d6bd1fcde781596cb86068214edeebff1db13a2cea11079e3fd07b6b"}, {file = "lru_dict-1.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4073333894db9840f066226d50e6f914a2240711c87d60885d8c940b69a6673f"}, @@ -1339,6 +1364,7 @@ version = "3.8.2" description = "Python implementation of John Gruber's Markdown." optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "markdown-3.8.2-py3-none-any.whl", hash = "sha256:5c83764dbd4e00bdd94d85a19b8d55ccca20fe35b2e678a1422b380324dd5f24"}, {file = "markdown-3.8.2.tar.gz", hash = "sha256:247b9a70dd12e27f67431ce62523e675b866d254f900c4fe75ce3dda62237c45"}, @@ -1354,6 +1380,7 @@ version = "3.0.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, @@ -1378,6 +1405,7 @@ version = "3.0.2" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, @@ -1448,6 +1476,7 @@ version = "0.7.0" description = "McCabe checker, plugin for flake8" optional = false python-versions = ">=3.6" +groups = ["dev"] files = [ {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, @@ -1459,6 +1488,7 @@ version = "0.1.2" description = "Markdown URL utilities" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, @@ -1470,6 +1500,7 @@ version = "1.3.4" description = "A deep merge function for 🐍." optional = false python-versions = ">=3.6" +groups = ["dev"] files = [ {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, @@ -1481,6 +1512,7 @@ version = "1.6.1" description = "Project documentation with Markdown." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e"}, {file = "mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2"}, @@ -1511,6 +1543,7 @@ version = "0.4.1" description = "Automatically link across pages in MkDocs." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "mkdocs-autorefs-0.4.1.tar.gz", hash = "sha256:70748a7bd025f9ecd6d6feeba8ba63f8e891a1af55f48e366d6d6e78493aba84"}, {file = "mkdocs_autorefs-0.4.1-py3-none-any.whl", hash = "sha256:a2248a9501b29dc0cc8ba4c09f4f47ff121945f6ce33d760f145d6f89d313f5b"}, @@ -1526,6 +1559,7 @@ version = "0.2.0" description = "MkDocs extension that lists all dependencies according to a mkdocs.yml file" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134"}, {file = "mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c"}, @@ -1542,6 +1576,7 @@ version = "3.9.1" description = "Mkdocs Markdown includer plugin." optional = false python-versions = ">=3.6" +groups = ["dev"] files = [ {file = "mkdocs_include_markdown_plugin-3.9.1-py3-none-any.whl", hash = "sha256:f33687e29ac66d045ba181ea50f054646b0090b42b0a4318f08e7f1d1235e6f6"}, {file = "mkdocs_include_markdown_plugin-3.9.1.tar.gz", hash = "sha256:5e5698e78d7fea111be9873a456089daa333497988405acaac8eba2924a19152"}, @@ -1557,6 +1592,7 @@ version = "8.5.11" description = "Documentation that simply works" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "mkdocs_material-8.5.11-py3-none-any.whl", hash = "sha256:c907b4b052240a5778074a30a78f31a1f8ff82d7012356dc26898b97559f082e"}, {file = "mkdocs_material-8.5.11.tar.gz", hash = "sha256:b0ea0513fd8cab323e8a825d6692ea07fa83e917bb5db042e523afecc7064ab7"}, @@ -1577,6 +1613,7 @@ version = "1.3.1" description = "Extension pack for Python Markdown and MkDocs Material." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31"}, {file = "mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443"}, @@ -1588,6 +1625,7 @@ version = "6.5.1" description = "multidict implementation" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "multidict-6.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7b7d75cb5b90fa55700edbbdca12cd31f6b19c919e98712933c7a1c3c6c71b73"}, {file = "multidict-6.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ad32e43e028276612bf5bab762677e7d131d2df00106b53de2efb2b8a28d5bce"}, @@ -1698,6 +1736,7 @@ version = "1.1.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, @@ -1709,6 +1748,7 @@ version = "1.26.4" description = "Fundamental package for array computing in Python" optional = false python-versions = ">=3.9" +groups = ["main", "dev"] files = [ {file = "numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0"}, {file = "numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a"}, @@ -1754,6 +1794,7 @@ version = "25.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, @@ -1765,6 +1806,7 @@ version = "2.3.0" description = "Powerful data structures for data analysis, time series, and statistics" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "pandas-2.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:625466edd01d43b75b1883a64d859168e4556261a5035b32f9d743b67ef44634"}, {file = "pandas-2.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a6872d695c896f00df46b71648eea332279ef4077a409e2fe94220208b6bb675"}, @@ -1851,6 +1893,7 @@ version = "0.10.0" description = "(Soon to be) the fastest pure-Python PEG parser I could muster" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "parsimonious-0.10.0-py3-none-any.whl", hash = "sha256:982ab435fabe86519b57f6b35610aa4e4e977e9f02a14353edf4bbc75369fc0f"}, {file = "parsimonious-0.10.0.tar.gz", hash = "sha256:8281600da180ec8ae35427a4ab4f7b82bfec1e3d1e52f80cb60ea82b9512501c"}, @@ -1865,6 +1908,7 @@ version = "0.12.1" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, @@ -1876,6 +1920,7 @@ version = "4.3.8" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4"}, {file = "platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc"}, @@ -1892,6 +1937,7 @@ version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, @@ -1907,6 +1953,7 @@ version = "0.3.2" description = "Accelerated property cache" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "propcache-0.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:22d9962a358aedbb7a2e36187ff273adeaab9743373a272976d2e348d08c7770"}, {file = "propcache-0.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0d0fda578d1dc3f77b6b5a5dce3b9ad69a8250a891760a548df850a5e8da87f3"}, @@ -2014,6 +2061,7 @@ version = "6.31.1" description = "" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "protobuf-6.31.1-cp310-abi3-win32.whl", hash = "sha256:7fa17d5a29c2e04b7d90e5e32388b8bfd0e7107cd8e616feef7ed3fa6bdab5c9"}, {file = "protobuf-6.31.1-cp310-abi3-win_amd64.whl", hash = "sha256:426f59d2964864a1a366254fa703b8632dcec0790d8862d30034d8245e1cd447"}, @@ -2032,6 +2080,7 @@ version = "2.11.1" description = "Python style guide checker" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pycodestyle-2.11.1-py2.py3-none-any.whl", hash = "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67"}, {file = "pycodestyle-2.11.1.tar.gz", hash = "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f"}, @@ -2043,6 +2092,7 @@ version = "3.23.0" description = "Cryptographic library for Python" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main"] files = [ {file = "pycryptodome-3.23.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a176b79c49af27d7f6c12e4b178b0824626f40a7b9fed08f712291b6d54bf566"}, {file = "pycryptodome-3.23.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:573a0b3017e06f2cffd27d92ef22e46aa3be87a2d317a5abf7cc0e84e321bd75"}, @@ -2093,6 +2143,7 @@ version = "2.11.7" description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b"}, {file = "pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db"}, @@ -2114,6 +2165,7 @@ version = "2.33.2" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, @@ -2225,6 +2277,7 @@ version = "3.1.0" description = "passive checker of Python programs" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pyflakes-3.1.0-py2.py3-none-any.whl", hash = "sha256:4132f6d49cb4dae6819e5379898f2b8cce3c5f23994194c24b77d5da2e36f774"}, {file = "pyflakes-3.1.0.tar.gz", hash = "sha256:a0aae034c444db0071aa077972ba4768d40c830d9539fd45bf4cd3f8f6992efc"}, @@ -2236,6 +2289,7 @@ version = "2.19.2" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, @@ -2250,6 +2304,7 @@ version = "10.16" description = "Extension pack for Python Markdown." optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "pymdown_extensions-10.16-py3-none-any.whl", hash = "sha256:f5dd064a4db588cb2d95229fc4ee63a1b16cc8b4d0e6145c0899ed8723da1df2"}, {file = "pymdown_extensions-10.16.tar.gz", hash = "sha256:71dac4fca63fabeffd3eb9038b756161a33ec6e8d230853d3cecf562155ab3de"}, @@ -2268,6 +2323,7 @@ version = "7.4.4" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, @@ -2290,6 +2346,7 @@ version = "13.0" description = "pytest plugin to re-run tests to eliminate flaky failures" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "pytest-rerunfailures-13.0.tar.gz", hash = "sha256:e132dbe420bc476f544b96e7036edd0a69707574209b6677263c950d19b09199"}, {file = "pytest_rerunfailures-13.0-py3-none-any.whl", hash = "sha256:34919cb3fcb1f8e5d4b940aa75ccdea9661bade925091873b7c6fa5548333069"}, @@ -2305,6 +2362,7 @@ version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main", "dev"] files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -2319,6 +2377,7 @@ version = "0.17.1" description = "Read key-value pairs from a .env file and set them as environment variables" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "python-dotenv-0.17.1.tar.gz", hash = "sha256:b1ae5e9643d5ed987fc57cc2583021e38db531946518130777734f9589b3141f"}, {file = "python_dotenv-0.17.1-py2.py3-none-any.whl", hash = "sha256:00aa34e92d992e9f8383730816359647f358f4a3be1ba45e5a5cefd27ee91544"}, @@ -2333,6 +2392,7 @@ version = "2025.2" description = "World timezone definitions, modern and historical" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00"}, {file = "pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3"}, @@ -2344,6 +2404,7 @@ version = "16.0.0" description = "Unicode normalization forms (NFC, NFKC, NFD, NFKD). A library independent of the Python core Unicode database." optional = false python-versions = ">=3.6" +groups = ["main"] files = [ {file = "pyunormalize-16.0.0-py3-none-any.whl", hash = "sha256:c647d95e5d1e2ea9a2f448d1d95d8518348df24eab5c3fd32d2b5c3300a49152"}, {file = "pyunormalize-16.0.0.tar.gz", hash = "sha256:2e1dfbb4a118154ae26f70710426a52a364b926c9191f764601f5a8cb12761f7"}, @@ -2355,6 +2416,8 @@ version = "310" description = "Python for Window Extensions" optional = false python-versions = "*" +groups = ["main"] +markers = "platform_system == \"Windows\"" files = [ {file = "pywin32-310-cp310-cp310-win32.whl", hash = "sha256:6dd97011efc8bf51d6793a82292419eba2c71cf8e7250cfac03bba284454abc1"}, {file = "pywin32-310-cp310-cp310-win_amd64.whl", hash = "sha256:c3e78706e4229b915a0821941a84e7ef420bf2b77e08c9dae3c76fd03fd2ae3d"}, @@ -2380,6 +2443,7 @@ version = "6.0.2" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, @@ -2442,6 +2506,7 @@ version = "1.1" description = "A custom YAML tag for referencing environment variables in YAML files." optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04"}, {file = "pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff"}, @@ -2456,6 +2521,7 @@ version = "0.36.2" description = "JSON Referencing + Python" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0"}, {file = "referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa"}, @@ -2472,6 +2538,7 @@ version = "2024.11.6" description = "Alternative regular expression module, to replace re." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff590880083d60acc0433f9c3f713c51f7ac6ebb9adf889c79a261ecf541aa91"}, {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:658f90550f38270639e83ce492f27d2c8d2cd63805c65a13a14d36ca126753f0"}, @@ -2575,6 +2642,7 @@ version = "2.32.4" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, @@ -2615,6 +2683,7 @@ version = "14.0.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.8.0" +groups = ["main"] files = [ {file = "rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0"}, {file = "rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725"}, @@ -2634,6 +2703,7 @@ version = "1.8.9" description = "Format click help output nicely with rich" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "rich_click-1.8.9-py3-none-any.whl", hash = "sha256:c3fa81ed8a671a10de65a9e20abf642cfdac6fdb882db1ef465ee33919fbcfe2"}, {file = "rich_click-1.8.9.tar.gz", hash = "sha256:fd98c0ab9ddc1cf9c0b7463f68daf28b4d0033a74214ceb02f761b3ff2af3136"}, @@ -2654,6 +2724,7 @@ version = "4.1.0" description = "rlp: A package for Recursive Length Prefix encoding and decoding" optional = false python-versions = "<4,>=3.8" +groups = ["main"] files = [ {file = "rlp-4.1.0-py3-none-any.whl", hash = "sha256:8eca394c579bad34ee0b937aecb96a57052ff3716e19c7a578883e767bc5da6f"}, {file = "rlp-4.1.0.tar.gz", hash = "sha256:be07564270a96f3e225e2c107db263de96b5bc1f27722d2855bd3459a08e95a9"}, @@ -2674,6 +2745,7 @@ version = "0.25.1" description = "Python bindings to Rust's persistent data structures (rpds)" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "rpds_py-0.25.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:f4ad628b5174d5315761b67f212774a32f5bad5e61396d38108bd801c0a8f5d9"}, {file = "rpds_py-0.25.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8c742af695f7525e559c16f1562cf2323db0e3f0fbdcabdf6865b095256b2d40"}, @@ -2800,6 +2872,7 @@ version = "0.7.7" description = "Simple data validation library" optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "schema-0.7.7-py2.py3-none-any.whl", hash = "sha256:5d976a5b50f36e74e2157b47097b60002bd4d42e65425fcc9c9befadb4255dde"}, {file = "schema-0.7.7.tar.gz", hash = "sha256:7da553abd2958a19dc2547c388cde53398b39196175a9be59ea1caf5ab0a1807"}, @@ -2811,6 +2884,7 @@ version = "2.13.0" description = "Python helper for Semantic Versioning (http://semver.org/)" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["dev"] files = [ {file = "semver-2.13.0-py2.py3-none-any.whl", hash = "sha256:ced8b23dceb22134307c1b8abfa523da14198793d9787ac838e70e29e77458d4"}, {file = "semver-2.13.0.tar.gz", hash = "sha256:fa0fe2722ee1c3f57eac478820c3a5ae2f624af8264cbdf9000c980ff7f75e3f"}, @@ -2822,6 +2896,7 @@ version = "75.9.1" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "setuptools-75.9.1-py3-none-any.whl", hash = "sha256:0a6f876d62f4d978ca1a11ab4daf728d1357731f978543ff18ecdbf9fd071f73"}, {file = "setuptools-75.9.1.tar.gz", hash = "sha256:b6eca2c3070cdc82f71b4cb4bb2946bc0760a210d11362278cf1ff394e6ea32c"}, @@ -2842,6 +2917,7 @@ version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main", "dev"] files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, @@ -2853,6 +2929,7 @@ version = "0.9.0" description = "Pretty-print tabular data" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f"}, {file = "tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c"}, @@ -2867,6 +2944,7 @@ version = "6.11.0" description = "Bump software releases" optional = false python-versions = ">=3.7,<4.0" +groups = ["dev"] files = [ {file = "tbump-6.11.0-py3-none-any.whl", hash = "sha256:6b181fe6f3ae84ce0b9af8cc2009a8bca41ded34e73f623a7413b9684f1b4526"}, {file = "tbump-6.11.0.tar.gz", hash = "sha256:385e710eedf0a8a6ff959cf1e9f3cfd17c873617132fc0ec5f629af0c355c870"}, @@ -2927,6 +3005,7 @@ version = "0.11.8" description = "Style preserving TOML library" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "tomlkit-0.11.8-py3-none-any.whl", hash = "sha256:8c726c4c202bdb148667835f68d68780b9a003a9ec34167b6c673b38eff2a171"}, {file = "tomlkit-0.11.8.tar.gz", hash = "sha256:9330fc7faa1db67b541b28e62018c17d20be733177d290a13b24c62d1614e0c3"}, @@ -2938,6 +3017,8 @@ version = "1.0.0" description = "List processing tools and functional utilities" optional = false python-versions = ">=3.8" +groups = ["main"] +markers = "implementation_name == \"cpython\" or implementation_name == \"pypy\"" files = [ {file = "toolz-1.0.0-py3-none-any.whl", hash = "sha256:292c8f1c4e7516bf9086f8850935c799a874039c8bcf959d47b600e4c44a6236"}, {file = "toolz-1.0.0.tar.gz", hash = "sha256:2c86e3d9a04798ac556793bced838816296a2f085017664e4995cb40a1047a02"}, @@ -2954,6 +3035,7 @@ files = [ {file = "typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af"}, {file = "typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4"}, ] +markers = {dev = "python_version < \"3.11\""} [[package]] name = "typing-inspection" @@ -2961,6 +3043,7 @@ version = "0.4.1" description = "Runtime typing introspection tools" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, @@ -2975,6 +3058,7 @@ version = "2025.2" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" +groups = ["main"] files = [ {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, @@ -2986,6 +3070,7 @@ version = "1.4.0" description = "ASCII transliterations of Unicode text" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "Unidecode-1.4.0-py3-none-any.whl", hash = "sha256:c3c7606c27503ad8d501270406e345ddb480a7b5f38827eafe4fa82a137f0021"}, {file = "Unidecode-1.4.0.tar.gz", hash = "sha256:ce35985008338b676573023acc382d62c264f307c8f7963733405add37ea2b23"}, @@ -2997,6 +3082,7 @@ version = "2.5.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" +groups = ["main", "dev"] files = [ {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, @@ -3014,6 +3100,7 @@ version = "6.0.0" description = "Filesystem events monitoring" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26"}, {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112"}, @@ -3056,6 +3143,7 @@ version = "6.11.0" description = "web3.py" optional = false python-versions = ">=3.7.2" +groups = ["main"] files = [ {file = "web3-6.11.0-py3-none-any.whl", hash = "sha256:44e79da6a4765eacf137f2f388e37aa0c1e24a93bdfb462cffe9441d1be3d509"}, {file = "web3-6.11.0.tar.gz", hash = "sha256:050dea52ae73d787272e7ecba7249f096595938c90cce1a384c20375c6b0f720"}, @@ -3091,6 +3179,7 @@ version = "0.59.0" description = "WebSocket client for Python with low level API options" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["main"] files = [ {file = "websocket-client-0.59.0.tar.gz", hash = "sha256:d376bd60eace9d437ab6d7ee16f4ab4e821c9dae591e1b783c58ebd8aaf80c5c"}, {file = "websocket_client-0.59.0-py2.py3-none-any.whl", hash = "sha256:2e50d26ca593f70aba7b13a489435ef88b8fc3b5c5643c1ce8808ff9b40f0b32"}, @@ -3105,6 +3194,7 @@ version = "15.0.1" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b"}, {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205"}, @@ -3183,6 +3273,7 @@ version = "1.20.1" description = "Yet another URL library" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "yarl-1.20.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6032e6da6abd41e4acda34d75a816012717000fa6839f37124a47fcefc49bec4"}, {file = "yarl-1.20.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2c7b34d804b8cf9b214f05015c4fee2ebe7ed05cf581e7192c06555c71f4446a"}, @@ -3295,26 +3386,7 @@ idna = ">=2.0" multidict = ">=4.0" propcache = ">=0.2.1" -[[package]] -name = "zipp" -version = "3.23.0" -description = "Backport of pathlib-compatible object wrapper for zip files" -optional = false -python-versions = ">=3.9" -files = [ - {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, - {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] -type = ["pytest-mypy"] - [metadata] -lock-version = "2.0" -python-versions = ">=3.9,<=3.12" -content-hash = "b881eb7fc55ca464057b0c20f6daf1d00383590abaf634eafcc1f1b3596d1281" +lock-version = "2.1" +python-versions = ">=3.10,<=3.12" +content-hash = "87604146d8dc7947d3e2e6ae74575cee04664dd1d816ffd7815a6ca5da30c9c7" From bb42a2b8938f23b4a1668c7359315ccf5d688d0c Mon Sep 17 00:00:00 2001 From: zarathustra Date: Sun, 10 Aug 2025 16:42:09 +0200 Subject: [PATCH 020/101] feat: finality exceptions --- derive_client/exceptions.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/derive_client/exceptions.py b/derive_client/exceptions.py index c40a58c0..5e8b84da 100644 --- a/derive_client/exceptions.py +++ b/derive_client/exceptions.py @@ -75,3 +75,15 @@ class DeriveFundingFailed(Exception): class DrvWithdrawAmountBelowFee(Exception): """Raised when the DRV withdrawal amount is less than the fee required to withdraw.""" + + +class FinalityTimeout(Exception): + """Raised when the transaction was mined but did not reach the required finality within the timeout.""" + + +class TxPendingTimeout(Exception): + """Raised when the transaction receipt does not materialize and the transaction remains in the mempool.""" + + +class TransactionDropped(Exception): + """Raised when the transaction the transaction is no longer in the mempool, likely dropped.""" From 852387aa74029409ca283654cbc8d67f7ada6e8e Mon Sep 17 00:00:00 2001 From: zarathustra Date: Sun, 10 Aug 2025 16:42:31 +0200 Subject: [PATCH 021/101] feat: wait_for_tx_finality --- derive_client/utils/w3.py | 69 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/derive_client/utils/w3.py b/derive_client/utils/w3.py index 9899fbac..d1e71642 100644 --- a/derive_client/utils/w3.py +++ b/derive_client/utils/w3.py @@ -19,7 +19,14 @@ from derive_client.constants import ABI_DATA_DIR, DEFAULT_RPC_ENDPOINTS, GAS_FEE_BUFFER, GAS_LIMIT_BUFFER from derive_client.data_types import ChainID, RPCEndpoints, TxResult, TxStatus -from derive_client.exceptions import InsufficientNativeBalance, NoAvailableRPC, TxSubmissionError +from derive_client.exceptions import ( + FinalityTimeout, + InsufficientNativeBalance, + NoAvailableRPC, + TransactionDropped, + TxPendingTimeout, + TxSubmissionError, +) from derive_client.utils.logger import get_logger from derive_client.utils.retry import exp_backoff_retry @@ -217,9 +224,10 @@ def build_standard_transaction( def wait_for_tx_receipt(w3: Web3, tx_hash: str, timeout=120, poll_interval=1) -> AttributeDict: start_time = time.monotonic() + while True: try: - receipt = w3.eth.get_transaction_receipt(tx_hash) + receipt = AttributeDict(w3.eth.get_transaction_receipt(tx_hash)) except Exception: receipt = None if receipt is not None: @@ -229,6 +237,63 @@ def wait_for_tx_receipt(w3: Web3, tx_hash: str, timeout=120, poll_interval=1) -> time.sleep(poll_interval) +def wait_for_tx_finality( + w3: Web3, + tx_hash: str, + finality_blocks: int = 10, + timeout: float = 300.0, + poll_interval: float = 1.0, +): + """ + Wait until tx is mined and has `finality_blocks` confirmations. + On timeout this raises one of: + - FinalityTimeout: receipt exists but not enough confirmations + - TxPendingTimeout: no receipt, but tx present and pending in mempool + - TransactionDropped: no receipt and tx not known to node (likely dropped) + """ + + start_time = time.monotonic() + + while True: + try: + receipt = AttributeDict(w3.eth.get_transaction_receipt(tx_hash)) + # receipt can disappear temporarily during reorgs, or if RPC provider is not synced + except Exception: + receipt = None + # blockNumber can change as tx gets reorged into different blocks + if receipt is not None and (block_number := w3.eth.block_number) >= receipt.blockNumber + finality_blocks: + return receipt + + if time.monotonic() - start_time > timeout: + # 1) We have a receipt but did not reach required confirmations + if receipt is not None: + raise FinalityTimeout( + f"Timed out waiting for finality: tx={tx_hash!r}, timeout_s={timeout}. ", + f"Required confirmations={finality_blocks}, " + f"receipt_block={receipt.blockNumber!r}, current_block={block_number!r}, ", + ) + # 2) No receipt: check if tx is known to node (mempool) or dropped + try: + tx = AttributeDict(w3.eth.get_transaction(tx_hash)) + except Exception: + tx = None + # still pending in mempool (covers possible tx receipt disappearance during reorg) + if tx is not None and tx.blockNumber is None: + raise TxPendingTimeout( + f"No receipt within timeout: tx={tx_hash!r}, timeout_s={timeout}. ", + "Node reports transaction present and pending in mempool. ", + "Consider waiting longer or replacing with higher gas (same nonce).", + ) + # tx dropped or node no longer knows about it + else: + raise TransactionDropped( + f"Transaction not found after timeout: tx={tx_hash!r}, timeout_s={timeout}. ", + "Node does not report a receipt or pending transaction. ", + "Likely dropped; consider resubmitting with same nonce or check node sync/peers.", + ) + time.sleep(poll_interval) + + def sign_and_send_tx(w3: Web3, tx: dict, private_key: str, logger: Logger) -> HexBytes: signed_tx = w3.eth.account.sign_transaction(tx, private_key=private_key) logger.debug(f"signed_tx: {signed_tx}") From 91610c820d8d1334e89b7e2d1faa822d119980f5 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Mon, 11 Aug 2025 21:40:46 +0200 Subject: [PATCH 022/101] fix: wait_for_tx_finality --- derive_client/utils/w3.py | 46 +++++++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/derive_client/utils/w3.py b/derive_client/utils/w3.py index d1e71642..b25f7c23 100644 --- a/derive_client/utils/w3.py +++ b/derive_client/utils/w3.py @@ -243,13 +243,28 @@ def wait_for_tx_finality( finality_blocks: int = 10, timeout: float = 300.0, poll_interval: float = 1.0, -): +) -> AttributeDict: """ Wait until tx is mined and has `finality_blocks` confirmations. On timeout this raises one of: - FinalityTimeout: receipt exists but not enough confirmations - TxPendingTimeout: no receipt, but tx present and pending in mempool - TransactionDropped: no receipt and tx not known to node (likely dropped) + + Notes on reorgs and provider inconsistency: + - A chain reorg can cause a previously-seen receipt to disappear (tx becomes "unmined"). + In that case the tx will often reappear as pending in the mempool (TxPendingTimeout), + but it can also be dropped entirely (TransactionDropped) or re-mined later. + - With rotating RPC providers you may observe receipts, tx entries, and block numbers + from different nodes that disagree. This function classifies a timeout based on a + single get_transaction probe and is intentionally conservative; callers should + interpret exceptions as: + * FinalityTimeout: node reports mined or we observed a receipt but not enough confirms: + wait longer; invoke this function again. + * TxPendingTimeout: node knows the tx and reports it pending: + either wait/poll longer or resubmit (reuse the nonce to prevent duplication). + * TransactionDropped: node has no record (likely dropped or node out-of-sync): + either wait/poll longer or resubmit (reuse the nonce to prevent duplication). """ start_time = time.monotonic() @@ -268,28 +283,37 @@ def wait_for_tx_finality( # 1) We have a receipt but did not reach required confirmations if receipt is not None: raise FinalityTimeout( - f"Timed out waiting for finality: tx={tx_hash!r}, timeout_s={timeout}. ", - f"Required confirmations={finality_blocks}, " - f"receipt_block={receipt.blockNumber!r}, current_block={block_number!r}, ", + f"Timed out waiting for finality: tx={tx_hash!r}, timeout_s={timeout}r ", + f"required confirmations={finality_blocks}." + f"\nreceipt_block={receipt.blockNumber!r}, current_block={block_number!r}.", + "\nAction: wait longer / poll for finality again.", ) # 2) No receipt: check if tx is known to node (mempool) or dropped try: tx = AttributeDict(w3.eth.get_transaction(tx_hash)) except Exception: tx = None - # still pending in mempool (covers possible tx receipt disappearance during reorg) + # still pending in mempool if tx is not None and tx.blockNumber is None: raise TxPendingTimeout( - f"No receipt within timeout: tx={tx_hash!r}, timeout_s={timeout}. ", - "Node reports transaction present and pending in mempool. ", - "Consider waiting longer or replacing with higher gas (same nonce).", + f"No receipt within timeout: tx={tx_hash!r}, timeout_s={timeout}.", + "\nNode reports transaction present and pending in mempool.", + "\nAction: either wait/poll longer or resubmit (reuse the nonce to prevent duplication).", + ) + # node reports tx mined, but no receipt + elif tx is not None: + raise FinalityTimeout( + f"Timed out waiting for finality: tx={tx_hash!r}, timeout_s={timeout}, " + f"required confirmations={finality_blocks}." + f"\nNode reports tx mined at block {tx.blockNumber!r} but receipt was not observed by this verifier." + "\nAction: wait longer / poll for finality again.", ) # tx dropped or node no longer knows about it else: raise TransactionDropped( - f"Transaction not found after timeout: tx={tx_hash!r}, timeout_s={timeout}. ", - "Node does not report a receipt or pending transaction. ", - "Likely dropped; consider resubmitting with same nonce or check node sync/peers.", + f"Transaction not found after timeout: tx={tx_hash!r}, timeout_s={timeout}.", + "\nNode does not report a receipt or pending transaction (likely dropped).", + "\nAction: either wait/poll longer or resubmit (reuse the nonce to prevent duplication).", ) time.sleep(poll_interval) From c25599e1be05c695ad9bb8530938f171eda1636b Mon Sep 17 00:00:00 2001 From: zarathustra Date: Tue, 12 Aug 2025 20:35:27 +0200 Subject: [PATCH 023/101] feat: add BridgeTxDetails and PreparedBridgeTx --- derive_client/data_types/__init__.py | 4 + derive_client/data_types/models.py | 109 ++++++++++++++++++++++++++- 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/derive_client/data_types/__init__.py b/derive_client/data_types/__init__.py index ea4097a3..9a4b2014 100644 --- a/derive_client/data_types/__init__.py +++ b/derive_client/data_types/__init__.py @@ -28,6 +28,7 @@ from .models import ( Address, BridgeContext, + BridgeTxDetails, BridgeTxResult, CreateSubAccountData, CreateSubAccountDetails, @@ -37,6 +38,7 @@ ManagerAddress, MintableTokenData, NonMintableTokenData, + PreparedBridgeTx, RPCEndpoints, SessionKey, TxResult, @@ -82,4 +84,6 @@ "DeriveTxResult", "SocketAddress", "RPCEndpoints", + "BridgeTxDetails", + "PreparedBridgeTx", ] diff --git a/derive_client/data_types/models.py b/derive_client/data_types/models.py index 5d8fc11d..b36636d6 100644 --- a/derive_client/data_types/models.py +++ b/derive_client/data_types/models.py @@ -1,9 +1,13 @@ """Models used in the bridge module.""" +from typing import Any + from derive_action_signing.module_data import ModuleData from derive_action_signing.utils import decimal_to_big_int from eth_abi.abi import encode +from eth_account.datastructures import SignedTransaction from eth_utils import is_0x_prefixed, is_address, is_hex, to_checksum_address +from hexbytes import HexBytes from pydantic import BaseModel, ConfigDict, Field, GetCoreSchemaHandler, GetJsonSchemaHandler, HttpUrl from pydantic.dataclasses import dataclass from pydantic_core import core_schema @@ -32,6 +36,70 @@ def _validate(cls, v) -> AttributeDict: return AttributeDict(v) +class PHexBytes(HexBytes): + @classmethod + def __get_pydantic_core_schema__(cls, _source: Any, _handler: Any) -> core_schema.CoreSchema: + # Allow either HexBytes or bytes/hex strings to be parsed into HexBytes + return core_schema.no_info_before_validator_function( + cls._validate, + core_schema.union_schema( + [ + core_schema.is_instance_schema(HexBytes), + core_schema.bytes_schema(), + core_schema.str_schema(), + ] + ), + ) + + @classmethod + def __get_pydantic_json_schema__(cls, _schema: core_schema.CoreSchema, _handler: Any) -> dict: + return {"type": "string", "format": "hex"} + + @classmethod + def _validate(cls, v: Any) -> HexBytes: + if isinstance(v, HexBytes): + return v + if isinstance(v, (bytes, bytearray)): + return HexBytes(v) + if isinstance(v, str): + return HexBytes(v) + raise TypeError(f"Expected HexBytes-compatible type, got {type(v).__name__}") + + +class PSignedTransaction(SignedTransaction): + @classmethod + def __get_pydantic_core_schema__(cls, _source: Any, _handler: Any) -> core_schema.CoreSchema: + # Accept existing SignedTransaction or a tuple/dict of its fields + return core_schema.no_info_plain_validator_function(cls._validate) + + @classmethod + def __get_pydantic_json_schema__(cls, _schema: core_schema.CoreSchema, _handler: Any) -> dict: + return { + "type": "object", + "properties": { + "raw_transaction": {"type": "string", "format": "hex"}, + "hash": {"type": "string", "format": "hex"}, + "r": {"type": "integer"}, + "s": {"type": "integer"}, + "v": {"type": "integer"}, + }, + } + + @classmethod + def _validate(cls, v: Any) -> SignedTransaction: + if isinstance(v, SignedTransaction): + return v + if isinstance(v, dict): + return SignedTransaction( + raw_transaction=PHexBytes(v["raw_transaction"]), + hash=PHexBytes(v["hash"]), + r=int(v["r"]), + s=int(v["s"]), + v=int(v["v"]), + ) + raise TypeError(f"Expected SignedTransaction or dict, got {type(v).__name__}") + + class Address(str): @classmethod def __get_pydantic_core_schema__(cls, _source, _handler: GetCoreSchemaHandler) -> core_schema.CoreSchema: @@ -151,6 +219,44 @@ def target_chain(self) -> ChainID: return ChainID(self.target_w3.eth.chain_id) +@dataclass +class BridgeTxDetails: + contract: Address + method: str + kwargs: dict[str, Any] + tx: dict[str, Any] + signed_tx: PSignedTransaction + + @property + def tx_hash(self) -> str: + """Pre-computed transaction hash.""" + return self.signed_tx.hash.to_0x_hex + + @property + def nonce(self) -> str: + """Transaction nonce.""" + return self.tx["nonce"] + + +@dataclass +class PreparedBridgeTx: + currency: Currency + bridge: BridgeType + source_chain: ChainID + target_chain: ChainID + tx_details: BridgeTxDetails + + @property + def tx_hash(self) -> str: + """Pre-computed transaction hash.""" + return self.tx_details.tx_hash + + @property + def nonce(self) -> str: + """Transaction nonce.""" + return self.tx_details.nonce + + @dataclass(config=ConfigDict(validate_assignment=True)) class TxResult: tx_hash: TxHash @@ -170,7 +276,8 @@ class BridgeTxResult: source_chain: ChainID target_chain: ChainID source_tx: TxResult - target_from_block: int + tx_details: BridgeTxDetails + target_from_block: int | None = None event_id: str | None = None target_tx: TxResult | None = None From bb6694fea1e577d0e059cd397cd209bdcb9ea3e0 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Tue, 12 Aug 2025 20:38:20 +0200 Subject: [PATCH 024/101] refactor: utils/w3.py --- derive_client/utils/__init__.py | 12 +++--- derive_client/utils/w3.py | 66 ++++++++++++--------------------- 2 files changed, 29 insertions(+), 49 deletions(-) diff --git a/derive_client/utils/__init__.py b/derive_client/utils/__init__.py index c71ca835..ad1540e2 100644 --- a/derive_client/utils/__init__.py +++ b/derive_client/utils/__init__.py @@ -15,10 +15,10 @@ load_rpc_endpoints, make_filter_params, make_rotating_provider_middleware, - send_and_confirm_tx, - sign_and_send_tx, + send_tx, + sign_tx, wait_for_event, - wait_for_tx_receipt, + wait_for_tx_finality, ) __all__ = [ @@ -34,9 +34,9 @@ "get_contract", "get_erc20_contract", "load_rpc_endpoints", - "wait_for_tx_receipt", - "sign_and_send_tx", - "send_and_confirm_tx", + "wait_for_tx_finality", + "sign_tx", + "send_tx", "download_prod_address_abis", "build_standard_transaction", "iter_events", diff --git a/derive_client/utils/w3.py b/derive_client/utils/w3.py index b25f7c23..f876d029 100644 --- a/derive_client/utils/w3.py +++ b/derive_client/utils/w3.py @@ -9,7 +9,7 @@ import yaml from eth_account import Account -from hexbytes import HexBytes +from eth_account.datastructures import SignedTransaction from requests import RequestException from web3 import Web3 from web3.contract import Contract @@ -18,14 +18,13 @@ from web3.providers.rpc import HTTPProvider from derive_client.constants import ABI_DATA_DIR, DEFAULT_RPC_ENDPOINTS, GAS_FEE_BUFFER, GAS_LIMIT_BUFFER -from derive_client.data_types import ChainID, RPCEndpoints, TxResult, TxStatus +from derive_client.data_types import ChainID, RPCEndpoints from derive_client.exceptions import ( FinalityTimeout, InsufficientNativeBalance, NoAvailableRPC, TransactionDropped, TxPendingTimeout, - TxSubmissionError, ) from derive_client.utils.logger import get_logger from derive_client.utils.retry import exp_backoff_retry @@ -240,6 +239,7 @@ def wait_for_tx_receipt(w3: Web3, tx_hash: str, timeout=120, poll_interval=1) -> def wait_for_tx_finality( w3: Web3, tx_hash: str, + logger: Logger, finality_blocks: int = 10, timeout: float = 300.0, poll_interval: float = 1.0, @@ -273,11 +273,17 @@ def wait_for_tx_finality( try: receipt = AttributeDict(w3.eth.get_transaction_receipt(tx_hash)) # receipt can disappear temporarily during reorgs, or if RPC provider is not synced - except Exception: + except Exception as exc: receipt = None + logger.debug("No tx receipt for tx_hash=%s", tx_hash, extra={"exc": exc}) + # blockNumber can change as tx gets reorged into different blocks - if receipt is not None and (block_number := w3.eth.block_number) >= receipt.blockNumber + finality_blocks: - return receipt + try: + if receipt is not None and (block_number := w3.eth.block_number) >= receipt.blockNumber + finality_blocks: + return receipt + except Exception as exc: + msg = "Failed to fetch block_number trying to assess finality of tx_hash=%s" + logger.debug(msg, tx_hash, extra={"exc": exc}) if time.monotonic() - start_time > timeout: # 1) We have a receipt but did not reach required confirmations @@ -291,8 +297,10 @@ def wait_for_tx_finality( # 2) No receipt: check if tx is known to node (mempool) or dropped try: tx = AttributeDict(w3.eth.get_transaction(tx_hash)) - except Exception: + except Exception as exc: tx = None + logger.debug("get_transaction probe failed for tx_hash=%s", tx_hash, extra={"exc": exc}) + # still pending in mempool if tx is not None and tx.blockNumber is None: raise TxPendingTimeout( @@ -305,7 +313,7 @@ def wait_for_tx_finality( raise FinalityTimeout( f"Timed out waiting for finality: tx={tx_hash!r}, timeout_s={timeout}, " f"required confirmations={finality_blocks}." - f"\nNode reports tx mined at block {tx.blockNumber!r} but receipt was not observed by this verifier." + f"\nNode reports tx mined at block {tx.blockNumber!r} but receipt not observed by this verifier." "\nAction: wait longer / poll for finality again.", ) # tx dropped or node no longer knows about it @@ -315,47 +323,19 @@ def wait_for_tx_finality( "\nNode does not report a receipt or pending transaction (likely dropped).", "\nAction: either wait/poll longer or resubmit (reuse the nonce to prevent duplication).", ) + + logger.debug("Waiting for finality: tx=%s sleeping=%.1fs", tx_hash, poll_interval) time.sleep(poll_interval) -def sign_and_send_tx(w3: Web3, tx: dict, private_key: str, logger: Logger) -> HexBytes: +def sign_tx(w3: Web3, tx: dict, private_key: str) -> SignedTransaction: signed_tx = w3.eth.account.sign_transaction(tx, private_key=private_key) - logger.debug(f"signed_tx: {signed_tx}") - tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction) - logger.debug(f"tx_hash: {tx_hash.to_0x_hex()}") - return tx_hash + return signed_tx -def send_and_confirm_tx( - w3: Web3, - tx: dict, - private_key: str, - *, - action: str, # e.g. "approve()", "deposit()", "withdraw()" - logger: Logger, -) -> TxResult: - """Send and confirm transactions.""" - - try: - tx_hash = sign_and_send_tx(w3=w3, tx=tx, private_key=private_key, logger=logger) - tx_result = TxResult(tx_hash=tx_hash.to_0x_hex(), tx_receipt=None, exception=None) - except Exception as send_err: - msg = f"❌ Failed to send tx for {action}, error: {send_err!r}" - logger.error(msg) - raise TxSubmissionError(msg) from send_err - - try: - tx_result.tx_receipt = wait_for_tx_receipt(w3=w3, tx_hash=tx_hash) - except TimeoutError: - logger.warning(f"⏱️ Timeout waiting for tx receipt of {tx_hash.to_0x_hex()}") - return tx_result - - if tx_result.tx_receipt.status == TxStatus.SUCCESS: - logger.info(f"✅ {action} succeeded for tx {tx_hash.to_0x_hex()}") - else: - logger.error(f"❌ {action} reverted for tx {tx_hash.to_0x_hex()}") - - return tx_result +def send_tx(w3: Web3, signed_tx: SignedTransaction) -> str: + tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction) + return tx_hash.to_0x_hex() def estimate_fees(w3, percentiles: list[int], blocks=20, default_tip=10_000): From 4e392011fff0b39446de594eea56a0f57cce5b5c Mon Sep 17 00:00:00 2001 From: zarathustra Date: Tue, 12 Aug 2025 20:38:44 +0200 Subject: [PATCH 025/101] fix: _increase_token_allowance to wait for wait_for_tx_finality --- derive_client/_bridge/transaction.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/derive_client/_bridge/transaction.py b/derive_client/_bridge/transaction.py index e0eb6e31..5c6de860 100644 --- a/derive_client/_bridge/transaction.py +++ b/derive_client/_bridge/transaction.py @@ -6,7 +6,7 @@ from derive_client.data_types import Address, TxStatus from derive_client.exceptions import InsufficientTokenBalance -from derive_client.utils import build_standard_transaction, send_and_confirm_tx +from derive_client.utils import build_standard_transaction, send_tx, sign_tx, wait_for_tx_finality def ensure_token_balance(token_contract: Contract, owner: Address, amount: int): @@ -51,6 +51,8 @@ def _increase_token_allowance( ) -> None: func = erc20_contract.functions.approve(spender, amount) tx = build_standard_transaction(func=func, account=from_account, w3=w3) - tx_result = send_and_confirm_tx(w3=w3, tx=tx, private_key=private_key, action="approve()", logger=logger) - if tx_result.status != TxStatus.SUCCESS: + signed_tx = sign_tx(w3=w3, tx=tx, private_key=private_key) + tx_hash = send_tx(w3=w3, signed_tx=signed_tx) + tx_receipt = wait_for_tx_finality(w3=w3, tx_hash=tx_hash, logger=logger) + if tx_receipt.status != TxStatus.SUCCESS: raise RuntimeError("approve() failed") From 6a71c58e2386db6c8e6daf80cd136e1a41f92f9e Mon Sep 17 00:00:00 2001 From: zarathustra Date: Tue, 12 Aug 2025 20:40:25 +0200 Subject: [PATCH 026/101] refactor: BridgeClient methods, removing poll_bridge_progress and deriva gas funding --- derive_client/_bridge/client.py | 322 ++++++++++++++------------------ 1 file changed, 142 insertions(+), 180 deletions(-) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index a4c2890e..33c2017b 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -4,10 +4,8 @@ from __future__ import annotations -import copy import functools import json -from contextlib import suppress from logging import Logger from typing import Literal @@ -21,13 +19,11 @@ CONFIGS, CONTROLLER_ABI_PATH, CONTROLLER_V0_ABI_PATH, - DEFAULT_GAS_FUNDING_AMOUNT, DEPOSIT_HELPER_ABI_PATH, DEPOSIT_HOOK_ABI_PATH, DERIVE_ABI_PATH, DERIVE_L2_ABI_PATH, ERC20_ABI_PATH, - L1_STANDARD_BRIDGE_ABI_PATH, LIGHT_ACCOUNT_ABI_PATH, LYRA_OFT_WITHDRAW_WRAPPER_ABI_PATH, LYRA_OFT_WITHDRAW_WRAPPER_ADDRESS, @@ -42,6 +38,7 @@ from derive_client.data_types import ( Address, BridgeContext, + BridgeTxDetails, BridgeTxResult, BridgeType, ChainID, @@ -51,19 +48,15 @@ LayerZeroChainIDv2, MintableTokenData, NonMintableTokenData, + PreparedBridgeTx, SocketAddress, TxResult, - TxStatus, ) from derive_client.exceptions import ( - AlreadyFinalizedError, BridgeEventParseError, BridgePrimarySignerRequiredError, BridgeRouteError, - DeriveFundingFailed, DrvWithdrawAmountBelowFee, - EthGasFundingPending, - InsufficientNativeBalance, ) from derive_client.utils import ( build_standard_transaction, @@ -71,9 +64,10 @@ get_prod_derive_addresses, get_w3_connection, make_filter_params, - send_and_confirm_tx, + send_tx, + sign_tx, wait_for_event, - wait_for_tx_receipt, + wait_for_tx_finality, ) @@ -219,6 +213,15 @@ def _make_bridge_context( raise BridgeRouteError(f"Unsupported bridge_type={bridge_type} for currency={currency}.") + def _get_context(self, state: PreparedBridgeTx | BridgeTxResult) -> BridgeContext: + direction = "withdraw" if state.source_chain == ChainID.DERIVE else "deposit" + context = self._make_bridge_context( + direction=direction, + bridge_type=state.bridge, + currency=state.currency, + ) + return context + def _resolve_socket_route( self, direction: Literal["deposit", "withdraw"], @@ -245,15 +248,37 @@ def _resolve_socket_route( return src_token_data, src_token_data.connectors[tgt_chain][TARGET_SPEED] - def deposit(self, amount: int, currency: Currency) -> BridgeTxResult: + def _prepare_tx(self, func, value, currency, context) -> PreparedBridgeTx: + + tx = build_standard_transaction(func=func, account=self.account, w3=context.source_w3, value=value) + signed_tx = sign_tx(w3=context.source_w3, tx=tx, private_key=self.private_key) + + tx_details = BridgeTxDetails( + contract=func.address, + method=func.fn_name, + kwargs=func.kwargs, + tx=tx, + signed_tx=signed_tx, + ) + + bridge = BridgeType.LAYERZERO if currency == Currency.DRV else BridgeType.SOCKET + prepared_tx = PreparedBridgeTx( + currency=currency, + bridge=bridge, + source_chain=context.source_chain, + target_chain=context.target_chain, + tx_details=tx_details, + ) + + return prepared_tx + + def prepare_deposit(self, amount: int, currency: Currency) -> PreparedBridgeTx: """ Deposit funds by preparing, signing, and sending a bridging transaction. """ - # record on target chain when we start polling token_data, _connector = self._resolve_socket_route("deposit", currency=currency) context = self._make_bridge_context("deposit", bridge_type=BridgeType.SOCKET, currency=currency) - target_from_block = context.target_w3.eth.block_number spender = token_data.Vault if token_data.isNewBridge else self.deposit_helper.address ensure_token_balance(context.source_token, self.owner, amount) @@ -268,34 +293,117 @@ def deposit(self, amount: int, currency: Currency) -> BridgeTxResult: ) if token_data.isNewBridge: - tx = self._prepare_new_style_deposit(token_data, amount) + func, fees = self._prepare_new_style_deposit(token_data, amount) else: - tx = self._prepare_old_style_deposit(token_data, amount) + func, fees = self._prepare_old_style_deposit(token_data, amount) + + prepared_tx = self._prepare_tx(func=func, value=fees + 1, currency=currency, context=context) + return prepared_tx + + def send_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResult: + + context = self._get_context(prepared_tx) + + # record on target chain where we should start polling + target_from_block = context.target_w3.eth.block_number + + signed_tx = prepared_tx.tx_details.signed_tx + tx_hash = send_tx(w3=context.source_w3, signed_tx=signed_tx) + source_tx = TxResult(tx_hash=tx_hash) - source_tx = send_and_confirm_tx( - w3=context.source_w3, tx=tx, private_key=self.private_key, action="bridge()", logger=self.logger - ) tx_result = BridgeTxResult( - currency=currency, - bridge=BridgeType.SOCKET, + currency=prepared_tx.currency, + bridge=prepared_tx.bridge, source_chain=context.source_chain, target_chain=context.target_chain, source_tx=source_tx, target_from_block=target_from_block, + tx_details=prepared_tx.tx_details, + ) + + return tx_result + + def confirm_source_tx(self, tx_result: BridgeTxResult) -> BridgeTxResult: + + context = self._get_context(tx_result) + msg = "⏳ Checking source chain [%s] tx receipt for %s" + self.logger.info(msg, tx_result.source_chain.name, tx_result.source_tx.tx_hash) + tx_result.source_tx.tx_receipt = wait_for_tx_finality( + w3=context.source_w3, + tx_hash=tx_result.source_tx.tx_hash, + logger=self.logger, + ) + + return tx_result + + def wait_for_target_event(self, tx_result: BridgeTxResult) -> BridgeTxResult: + + bridge_event_fetchers = { + BridgeType.SOCKET: self.fetch_socket_event_log, + BridgeType.LAYERZERO: self.fetch_lz_event_log, + } + if (fetch_event := bridge_event_fetchers.get(tx_result.bridge)) is None: + raise BridgeRouteError(f"Invalid bridge_type: {tx_result.bridge}") + + context = self._get_context(tx_result) + event_log = fetch_event(tx_result, context) + tx_result.target_tx = TxResult(event_log["transactionHash"].to_0x_hex()) + self.logger.info(f"Target event tx_hash found: {tx_result.target_tx.tx_hash}") + + return tx_result + + def confirm_target_tx(self, tx_result: BridgeTxResult) -> BridgeTxResult: + + context = self._get_context(tx_result) + msg = "⏳ Checking target chain [%s] tx receipt for %s" + self.logger.info(msg, tx_result.target_chain.name, tx_result.target_tx.tx_hash) + tx_result.target_tx.tx_receipt = wait_for_tx_finality( + w3=context.target_w3, + tx_hash=tx_result.target_tx.tx_hash, + logger=self.logger, ) return tx_result - def withdraw_with_wrapper(self, amount: int, currency: Currency) -> BridgeTxResult: + def deposit(self, amount: int, currency: Currency) -> BridgeTxResult: + """ + Deposit funds by preparing, signing, and sending a bridging transaction. + """ + + if currency == Currency.DRV: + prepared_tx = self.prepare_deposit_drv(amount=amount, currency=currency) + else: + prepared_tx = self.prepare_deposit(amount=amount, currency=currency) + + tx_result = self.send_tx(prepared_tx=prepared_tx) + tx_result = self.confirm_source_tx(tx_result=tx_result) + tx_result = self.wait_for_target_event(tx_result=tx_result) + tx_result = self.confirm_target_tx(tx_result=tx_result) + + return tx_result + + def withdraw(self, amount: int, currency: Currency) -> BridgeTxResult: + + if currency == Currency.DRV: + prepared_tx = self.prepare_withdraw_drv(amount=amount, currency=currency) + else: + prepared_tx = self.prepare_withdraw(amount=amount, currency=currency) + + tx_result = self.send_tx(prepared_tx=prepared_tx) + tx_result = self.confirm_source_tx(tx_result=tx_result) + tx_result = self.wait_for_target_event(tx_result=tx_result) + tx_result = self.confirm_target_tx(tx_result=tx_result) + + return tx_result + + def prepare_withdraw(self, amount: int, currency: Currency) -> BridgeTxResult: """ Checks if sufficent gas is available in derive, if not funds the wallet. Prepares, signs, and sends a withdrawal transaction using the withdraw wrapper. """ - # record on target chain when we start polling token_data, connector = self._resolve_socket_route("withdraw", currency=currency) context = self._make_bridge_context("withdraw", bridge_type=BridgeType.SOCKET, currency=currency) - target_from_block = context.target_w3.eth.block_number ensure_token_balance(context.source_token, self.wallet, amount) @@ -319,40 +427,16 @@ def withdraw_with_wrapper(self, amount: int, currency: Currency) -> BridgeTxResu dest=[context.source_token.address, self.withdraw_wrapper.address], func=[approve_data, bridge_data], ) + prepared_tx = self._prepare_tx(func=func, value=0, currency=currency, context=context) - try: - tx = build_standard_transaction(func=func, account=self.account, w3=context.source_w3, value=0) - except InsufficientNativeBalance: - self._ensure_derive_eth_balance(tx) - self.logger.info("Balance top-up triggered; funds are pending. Cannot proceed with tx now.") - raise EthGasFundingPending("Awaiting ETH deposit for gas.") - - source_tx = send_and_confirm_tx( - w3=context.source_w3, - tx=tx, - private_key=self.private_key, - action="executeBatch()", - logger=self.logger, - ) - tx_result = BridgeTxResult( - currency=currency, - bridge=BridgeType.SOCKET, - source_chain=context.source_chain, - target_chain=context.target_chain, - source_tx=source_tx, - target_from_block=target_from_block, - ) - - return tx_result + return prepared_tx - def deposit_drv(self, amount: int, currency: Currency) -> BridgeTxResult: + def prepare_deposit_drv(self, amount: int, currency: Currency) -> PreparedBridgeTx: """ Deposit funds by preparing, signing, and sending a bridging transaction. """ - # record on target chain when we start polling context = self._make_bridge_context("deposit", bridge_type=BridgeType.LAYERZERO, currency=currency) - target_from_block = context.target_w3.eth.block_number # check allowance, if needed approve ensure_token_balance(context.source_token, self.owner, amount) @@ -386,31 +470,13 @@ def deposit_drv(self, amount: int, currency: Currency) -> BridgeTxResult: refund_address = self.owner func = context.source_token.functions.send(send_params, fees, refund_address) - tx = build_standard_transaction(func=func, account=self.account, w3=context.source_w3, value=native_fee) + prepared_tx = self._prepare_tx(func=func, value=native_fee, currency=currency, context=context) - source_tx = send_and_confirm_tx( - w3=context.source_w3, - tx=tx, - private_key=self.private_key, - action="executeBatch()", - logger=self.logger, - ) - tx_result = BridgeTxResult( - currency=currency, - bridge=BridgeType.LAYERZERO, - source_chain=context.source_chain, - target_chain=context.target_chain, - source_tx=source_tx, - target_from_block=target_from_block, - ) - - return tx_result + return prepared_tx - def withdraw_drv(self, amount: int, currency: Currency) -> BridgeTxResult: + def prepare_withdraw_drv(self, amount: int, currency: Currency) -> BridgeTxResult: - # record on target chain when we start polling context = self._make_bridge_context("withdraw", bridge_type=BridgeType.LAYERZERO, currency=currency) - target_from_block = context.target_w3.eth.block_number abi = json.loads(LYRA_OFT_WITHDRAW_WRAPPER_ABI_PATH.read_text()) withdraw_wrapper = get_contract(context.source_w3, LYRA_OFT_WITHDRAW_WRAPPER_ADDRESS, abi=abi) @@ -436,31 +502,8 @@ def withdraw_drv(self, amount: int, currency: Currency) -> BridgeTxResult: dest=[context.source_token.address, withdraw_wrapper.address], func=[approve_data, bridge_data], ) - - try: - tx = build_standard_transaction(func=func, account=self.account, w3=context.source_w3, value=0) - except InsufficientNativeBalance: - self._ensure_derive_eth_balance(tx) - self.logger.info("Balance top-up triggered; funds are pending. Cannot proceed with tx now.") - raise EthGasFundingPending("Awaiting ETH deposit for gas.") - - source_tx = send_and_confirm_tx( - w3=context.source_w3, - tx=tx, - private_key=self.private_key, - action="executeBatch()", - logger=self.logger, - ) - tx_result = BridgeTxResult( - currency=currency, - bridge=BridgeType.LAYERZERO, - source_chain=context.source_chain, - target_chain=context.target_chain, - source_tx=source_tx, - target_from_block=target_from_block, - ) - - return tx_result + prepared_tx = self._prepare_tx(func=func, value=0, currency=currency, context=context) + return prepared_tx def fetch_lz_event_log(self, tx_result: BridgeTxResult, context: BridgeContext): @@ -507,87 +550,6 @@ def matching_message_id(log: AttributeDict) -> bool: ) return wait_for_event(context.target_w3, filter_params, condition=matching_message_id, logger=self.logger) - def poll_bridge_progress(self, tx_result: BridgeTxResult) -> BridgeTxResult: - - if tx_result.status is not TxStatus.PENDING: - raise AlreadyFinalizedError(f"Bridge already in final state: {tx_result.status.name}") - - # Do not mutate the input in-place - tx_result = copy.deepcopy(tx_result) - - bridge_event_fetchers = { - BridgeType.SOCKET: self.fetch_socket_event_log, - BridgeType.LAYERZERO: self.fetch_lz_event_log, - } - if (fetch_event := bridge_event_fetchers.get(tx_result.bridge)) is None: - raise BridgeRouteError(f"Invalid bridge_type: {tx_result.bridge}") - - direction = "withdraw" if tx_result.source_chain == ChainID.DERIVE else "deposit" - context = self._make_bridge_context( - direction=direction, - bridge_type=tx_result.bridge, - currency=tx_result.currency, - ) - - # Timeout means partial update; subsequent steps depend on prior success, so we stop here - with suppress(TimeoutError): - # 1. TimeoutError as exception during source_tx.tx_receipt - if not tx_result.source_tx.tx_receipt: - msg = "⏳ Checking source chain [%s] tx receipt for %s" - self.logger.info(msg, tx_result.source_chain.name, tx_result.source_tx.tx_hash) - tx_result.source_tx.tx_receipt = wait_for_tx_receipt( - w3=context.source_w3, tx_hash=tx_result.source_tx.tx_hash - ) - - # 2. target_tx is None (i.e. TimeoutError when waiting for event log on target chain) - if not tx_result.target_tx: - event_log = fetch_event(tx_result, context) - tx_result.target_tx = TxResult(event_log["transactionHash"].to_0x_hex()) - - # 3. TimeoutError waiting for target_tx.tx_receipt - if not tx_result.target_tx.tx_receipt: - msg = "⏳ Checking target chain [%s] tx receipt for %s" - self.logger.info(msg, tx_result.target_chain.name, tx_result.target_tx.tx_hash) - tx_result.target_tx.tx_receipt = wait_for_tx_receipt( - w3=context.target_w3, tx_hash=tx_result.target_tx.tx_hash - ) - - return tx_result - - def _ensure_derive_eth_balance(self, tx: dict[str, str]): - """Ensure that the Derive EOA wallet has sufficient ETH balance for gas.""" - self.logger.info(f"Funding Derive EOA wallet with {DEFAULT_GAS_FUNDING_AMOUNT} ETH") - - w3 = get_w3_connection(ChainID.ETH, logger=self.logger) - - address = self.config.contracts.L1_CHUG_SPLASH_PROXY - bridge_abi = json.loads(L1_STANDARD_BRIDGE_ABI_PATH.read_text()) - proxy_contract = get_contract(w3=w3, address=address, abi=bridge_abi) - - func = proxy_contract.functions.bridgeETH( - MSG_GAS_LIMIT, # _minGasLimit, e.g. Optimism - b"", # _extraData - ) - - tx = build_standard_transaction(func=func, account=self.account, w3=w3, value=DEFAULT_GAS_FUNDING_AMOUNT) - try: - tx_result = send_and_confirm_tx( - w3=w3, tx=tx, private_key=self.private_key, action="bridgeETH()", logger=self.logger - ) - except InsufficientNativeBalance: - self.logger.warning("Insufficient native balance for bridging ETH to Derive.") - raise - - match tx_result.status: - case TxStatus.SUCCESS: - self.logger.info(f"Funding ttransactionx mined successfully: {tx_result}") - case TxStatus.PENDING: - self.logger.warning(f"Funding transaction is still pending: {tx_result}") - case TxStatus.FAILED: - msg = f"Funding transaction failed: {tx_result}" - self.logger.error(msg) - raise DeriveFundingFailed(msg) - def _prepare_new_style_deposit(self, token_data: NonMintableTokenData, amount: int) -> dict: vault_contract = _load_vault_contract(w3=self.remote_w3, token_data=token_data) connector = token_data.connectors[ChainID.DERIVE][TARGET_SPEED] @@ -600,7 +562,7 @@ def _prepare_new_style_deposit(self, token_data: NonMintableTokenData, amount: i extraData_=b"", options_=b"", ) - return build_standard_transaction(func=func, account=self.account, w3=self.remote_w3, value=fees + 1) + return func, fees def _prepare_old_style_deposit(self, token_data: NonMintableTokenData, amount: int) -> dict: vault_contract = _load_vault_contract(w3=self.remote_w3, token_data=token_data) @@ -614,7 +576,7 @@ def _prepare_old_style_deposit(self, token_data: NonMintableTokenData, amount: i gasLimit=MSG_GAS_LIMIT, connector=connector, ) - return build_standard_transaction(func=func, account=self.account, w3=self.remote_w3, value=fees + 1) + return func, fees def _check_bridge_funds(self, token_data, connector: Address, amount: int): controller = _load_controller_contract(w3=self.derive_w3, token_data=token_data) From 19f6c07cf32a5dee77dee37165196ba751311225 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Tue, 12 Aug 2025 20:40:41 +0200 Subject: [PATCH 027/101] mend rix: BridgeClient methods, removing poll_bridge_progress and deriva gas funding --- derive_client/cli.py | 4 ++-- derive_client/clients/base_client.py | 9 +++------ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/derive_client/cli.py b/derive_client/cli.py index c69688c8..5fcdce12 100644 --- a/derive_client/cli.py +++ b/derive_client/cli.py @@ -158,7 +158,7 @@ def deposit(ctx, chain_id, currency, amount): client: BaseClient = ctx.obj["client"] bridge_tx_result = client.deposit_to_derive(chain_id=chain_id, currency=currency, amount=amount) - bridge_tx_result = client.poll_bridge_progress(bridge_tx_result) + # bridge_tx_result = client.poll_bridge_progress(bridge_tx_result) match bridge_tx_result.status: case TxStatus.SUCCESS: @@ -208,7 +208,7 @@ def withdraw(ctx, chain_id, currency, amount): client: DeriveClient = ctx.obj["client"] bridge_tx_result = client.withdraw_from_derive(chain_id=chain_id, currency=currency, amount=amount) - bridge_tx_result = client.poll_bridge_progress(bridge_tx_result) + # bridge_tx_result = client.poll_bridge_progress(bridge_tx_result) match bridge_tx_result.status: case TxStatus.SUCCESS: diff --git a/derive_client/clients/base_client.py b/derive_client/clients/base_client.py index 1615786d..0a04567d 100644 --- a/derive_client/clients/base_client.py +++ b/derive_client/clients/base_client.py @@ -163,9 +163,6 @@ def deposit_to_derive_result( amount = int(amount * 10 ** TOKEN_DECIMALS[UnderlyingCurrency[currency.name.upper()]]) client = BridgeClient(self.env, chain_id, account=self.signer, wallet=self.wallet, logger=self.logger) - if currency == Currency.DRV: - return client.deposit_drv(amount=amount, currency=currency) - return client.deposit(amount=amount, currency=currency) def deposit_to_derive( @@ -215,10 +212,10 @@ def withdraw_from_derive_result( amount = int(amount * 10 ** TOKEN_DECIMALS[UnderlyingCurrency[currency.name.upper()]]) client = BridgeClient(self.env, chain_id, account=self.signer, wallet=self.wallet, logger=self.logger) - if currency == Currency.DRV: - return client.withdraw_drv(amount=amount, currency=currency) + # if currency == Currency.DRV: + # return client.withdraw(amount=amount, currency=currency) - return client.withdraw_with_wrapper(amount=amount, currency=currency) + return client.withdraw(amount=amount, currency=currency) def withdraw_from_derive( self, From 8a6b9a880b350f4acfe053859f5a8bd9317c3b87 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Tue, 12 Aug 2025 20:42:30 +0200 Subject: [PATCH 028/101] fix: cli and BaseClient withdraw and deposit commands --- derive_client/cli.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/derive_client/cli.py b/derive_client/cli.py index 5fcdce12..8c29cfd3 100644 --- a/derive_client/cli.py +++ b/derive_client/cli.py @@ -158,7 +158,6 @@ def deposit(ctx, chain_id, currency, amount): client: BaseClient = ctx.obj["client"] bridge_tx_result = client.deposit_to_derive(chain_id=chain_id, currency=currency, amount=amount) - # bridge_tx_result = client.poll_bridge_progress(bridge_tx_result) match bridge_tx_result.status: case TxStatus.SUCCESS: @@ -208,7 +207,6 @@ def withdraw(ctx, chain_id, currency, amount): client: DeriveClient = ctx.obj["client"] bridge_tx_result = client.withdraw_from_derive(chain_id=chain_id, currency=currency, amount=amount) - # bridge_tx_result = client.poll_bridge_progress(bridge_tx_result) match bridge_tx_result.status: case TxStatus.SUCCESS: From e2c880257b62481d2d9ba91a7ba4e72333b01f23 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Wed, 13 Aug 2025 12:20:29 +0200 Subject: [PATCH 029/101] refactor: BridgeClient functioo names and type annotations --- derive_client/_bridge/client.py | 242 ++++++++++++++------------- derive_client/clients/base_client.py | 9 +- 2 files changed, 129 insertions(+), 122 deletions(-) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index 33c2017b..e211d5e8 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -12,7 +12,9 @@ from eth_account import Account from web3 import Web3 from web3.contract import Contract +from web3.contract.contract import ContractFunction from web3.datastructures import AttributeDict +from web3.types import LogReceipt from derive_client._bridge.transaction import ensure_token_allowance, ensure_token_balance from derive_client.constants import ( @@ -146,7 +148,7 @@ def owner(self) -> Address: return self.light_account.functions.owner().call() @property - def private_key(self): + def private_key(self) -> str: """Private key of the owner (EOA) of the smart contract funding account.""" return self.account._private_key @@ -173,8 +175,12 @@ def _load_withdraw_wrapper(self) -> Contract: @functools.lru_cache def _make_bridge_context( - self, direction: Literal["deposit", "withdraw"], bridge_type: BridgeType, currency: Currency + self, + direction: Literal["deposit", "withdraw"], + bridge_type: BridgeType, + currency: Currency, ) -> BridgeContext: + is_deposit = direction == "deposit" src_w3, tgt_w3 = (self.remote_w3, self.derive_w3) if is_deposit else (self.derive_w3, self.remote_w3) src_chain, tgt_chain = ( @@ -248,7 +254,13 @@ def _resolve_socket_route( return src_token_data, src_token_data.connectors[tgt_chain][TARGET_SPEED] - def _prepare_tx(self, func, value, currency, context) -> PreparedBridgeTx: + def _prepare_tx( + self, + func: ContractFunction, + value: int, + currency: Currency, + context: BridgeContext, + ) -> PreparedBridgeTx: tx = build_standard_transaction(func=func, account=self.account, w3=context.source_w3, value=value) signed_tx = sign_tx(w3=context.source_w3, tx=tx, private_key=self.private_key) @@ -273,9 +285,33 @@ def _prepare_tx(self, func, value, currency, context) -> PreparedBridgeTx: return prepared_tx def prepare_deposit(self, amount: int, currency: Currency) -> PreparedBridgeTx: - """ - Deposit funds by preparing, signing, and sending a bridging transaction. - """ + + if currency == Currency.DRV: + prepared_tx = self.prepare_layerzero_deposit(amount=amount, currency=currency) + else: + prepared_tx = self.prepare_socket_deposit(amount=amount, currency=currency) + + return prepared_tx + + def prepare_withdrawal(self, amount: int, currency: Currency) -> PreparedBridgeTx: + + if currency == Currency.DRV: + prepared_tx = self.prepare_layerzero_withdrawal(amount=amount, currency=currency) + else: + prepared_tx = self.prepare_socket_withdrawal(amount=amount, currency=currency) + + return prepared_tx + + def submit_bridge_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResult: + + tx_result = self.send_tx(prepared_tx=prepared_tx) + tx_result = self.confirm_source_tx(tx_result=tx_result) + tx_result = self.wait_for_target_event(tx_result=tx_result) + tx_result = self.confirm_target_tx(tx_result=tx_result) + + return tx_result + + def prepare_socket_deposit(self, amount: int, currency: Currency) -> PreparedBridgeTx: token_data, _connector = self._resolve_socket_route("deposit", currency=currency) context = self._make_bridge_context("deposit", bridge_type=BridgeType.SOCKET, currency=currency) @@ -298,115 +334,15 @@ def prepare_deposit(self, amount: int, currency: Currency) -> PreparedBridgeTx: func, fees = self._prepare_old_style_deposit(token_data, amount) prepared_tx = self._prepare_tx(func=func, value=fees + 1, currency=currency, context=context) - return prepared_tx - - def send_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResult: - - context = self._get_context(prepared_tx) - - # record on target chain where we should start polling - target_from_block = context.target_w3.eth.block_number - - signed_tx = prepared_tx.tx_details.signed_tx - tx_hash = send_tx(w3=context.source_w3, signed_tx=signed_tx) - source_tx = TxResult(tx_hash=tx_hash) - - tx_result = BridgeTxResult( - currency=prepared_tx.currency, - bridge=prepared_tx.bridge, - source_chain=context.source_chain, - target_chain=context.target_chain, - source_tx=source_tx, - target_from_block=target_from_block, - tx_details=prepared_tx.tx_details, - ) - - return tx_result - - def confirm_source_tx(self, tx_result: BridgeTxResult) -> BridgeTxResult: - - context = self._get_context(tx_result) - msg = "⏳ Checking source chain [%s] tx receipt for %s" - self.logger.info(msg, tx_result.source_chain.name, tx_result.source_tx.tx_hash) - tx_result.source_tx.tx_receipt = wait_for_tx_finality( - w3=context.source_w3, - tx_hash=tx_result.source_tx.tx_hash, - logger=self.logger, - ) - - return tx_result - - def wait_for_target_event(self, tx_result: BridgeTxResult) -> BridgeTxResult: - - bridge_event_fetchers = { - BridgeType.SOCKET: self.fetch_socket_event_log, - BridgeType.LAYERZERO: self.fetch_lz_event_log, - } - if (fetch_event := bridge_event_fetchers.get(tx_result.bridge)) is None: - raise BridgeRouteError(f"Invalid bridge_type: {tx_result.bridge}") - - context = self._get_context(tx_result) - event_log = fetch_event(tx_result, context) - tx_result.target_tx = TxResult(event_log["transactionHash"].to_0x_hex()) - self.logger.info(f"Target event tx_hash found: {tx_result.target_tx.tx_hash}") - - return tx_result - - def confirm_target_tx(self, tx_result: BridgeTxResult) -> BridgeTxResult: - - context = self._get_context(tx_result) - msg = "⏳ Checking target chain [%s] tx receipt for %s" - self.logger.info(msg, tx_result.target_chain.name, tx_result.target_tx.tx_hash) - tx_result.target_tx.tx_receipt = wait_for_tx_finality( - w3=context.target_w3, - tx_hash=tx_result.target_tx.tx_hash, - logger=self.logger, - ) - - return tx_result - - def deposit(self, amount: int, currency: Currency) -> BridgeTxResult: - """ - Deposit funds by preparing, signing, and sending a bridging transaction. - """ - - if currency == Currency.DRV: - prepared_tx = self.prepare_deposit_drv(amount=amount, currency=currency) - else: - prepared_tx = self.prepare_deposit(amount=amount, currency=currency) - - tx_result = self.send_tx(prepared_tx=prepared_tx) - tx_result = self.confirm_source_tx(tx_result=tx_result) - tx_result = self.wait_for_target_event(tx_result=tx_result) - tx_result = self.confirm_target_tx(tx_result=tx_result) - - return tx_result - - def withdraw(self, amount: int, currency: Currency) -> BridgeTxResult: - - if currency == Currency.DRV: - prepared_tx = self.prepare_withdraw_drv(amount=amount, currency=currency) - else: - prepared_tx = self.prepare_withdraw(amount=amount, currency=currency) - - tx_result = self.send_tx(prepared_tx=prepared_tx) - tx_result = self.confirm_source_tx(tx_result=tx_result) - tx_result = self.wait_for_target_event(tx_result=tx_result) - tx_result = self.confirm_target_tx(tx_result=tx_result) - return tx_result + return prepared_tx - def prepare_withdraw(self, amount: int, currency: Currency) -> BridgeTxResult: - """ - Checks if sufficent gas is available in derive, if not funds the wallet. - Prepares, signs, and sends a withdrawal transaction using the withdraw wrapper. - """ + def prepare_socket_withdrawal(self, amount: int, currency: Currency) -> PreparedBridgeTx: token_data, connector = self._resolve_socket_route("withdraw", currency=currency) context = self._make_bridge_context("withdraw", bridge_type=BridgeType.SOCKET, currency=currency) ensure_token_balance(context.source_token, self.wallet, amount) - self._check_bridge_funds(token_data, connector, amount) kwargs = { @@ -431,10 +367,7 @@ def prepare_withdraw(self, amount: int, currency: Currency) -> BridgeTxResult: return prepared_tx - def prepare_deposit_drv(self, amount: int, currency: Currency) -> PreparedBridgeTx: - """ - Deposit funds by preparing, signing, and sending a bridging transaction. - """ + def prepare_layerzero_deposit(self, amount: int, currency: Currency) -> PreparedBridgeTx: context = self._make_bridge_context("deposit", bridge_type=BridgeType.LAYERZERO, currency=currency) @@ -474,7 +407,7 @@ def prepare_deposit_drv(self, amount: int, currency: Currency) -> PreparedBridge return prepared_tx - def prepare_withdraw_drv(self, amount: int, currency: Currency) -> BridgeTxResult: + def prepare_layerzero_withdrawal(self, amount: int, currency: Currency) -> PreparedBridgeTx: context = self._make_bridge_context("withdraw", bridge_type=BridgeType.LAYERZERO, currency=currency) @@ -503,9 +436,75 @@ def prepare_withdraw_drv(self, amount: int, currency: Currency) -> BridgeTxResul func=[approve_data, bridge_data], ) prepared_tx = self._prepare_tx(func=func, value=0, currency=currency, context=context) + return prepared_tx - def fetch_lz_event_log(self, tx_result: BridgeTxResult, context: BridgeContext): + def send_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResult: + + context = self._get_context(prepared_tx) + + # record on target chain where we should start polling + target_from_block = context.target_w3.eth.block_number + + signed_tx = prepared_tx.tx_details.signed_tx + tx_hash = send_tx(w3=context.source_w3, signed_tx=signed_tx) + source_tx = TxResult(tx_hash=tx_hash) + + tx_result = BridgeTxResult( + currency=prepared_tx.currency, + bridge=prepared_tx.bridge, + source_chain=context.source_chain, + target_chain=context.target_chain, + source_tx=source_tx, + target_from_block=target_from_block, + tx_details=prepared_tx.tx_details, + ) + + return tx_result + + def confirm_source_tx(self, tx_result: BridgeTxResult) -> BridgeTxResult: + + context = self._get_context(tx_result) + msg = "⏳ Checking source chain [%s] tx receipt for %s" + self.logger.info(msg, tx_result.source_chain.name, tx_result.source_tx.tx_hash) + tx_result.source_tx.tx_receipt = wait_for_tx_finality( + w3=context.source_w3, + tx_hash=tx_result.source_tx.tx_hash, + logger=self.logger, + ) + + return tx_result + + def wait_for_target_event(self, tx_result: BridgeTxResult) -> BridgeTxResult: + + bridge_event_fetchers = { + BridgeType.SOCKET: self.fetch_socket_event_log, + BridgeType.LAYERZERO: self.fetch_lz_event_log, + } + if (fetch_event := bridge_event_fetchers.get(tx_result.bridge)) is None: + raise BridgeRouteError(f"Invalid bridge_type: {tx_result.bridge}") + + context = self._get_context(tx_result) + event_log = fetch_event(tx_result, context) + tx_result.target_tx = TxResult(event_log["transactionHash"].to_0x_hex()) + self.logger.info(f"Target event tx_hash found: {tx_result.target_tx.tx_hash}") + + return tx_result + + def confirm_target_tx(self, tx_result: BridgeTxResult) -> BridgeTxResult: + + context = self._get_context(tx_result) + msg = "⏳ Checking target chain [%s] tx receipt for %s" + self.logger.info(msg, tx_result.target_chain.name, tx_result.target_tx.tx_hash) + tx_result.target_tx.tx_receipt = wait_for_tx_finality( + w3=context.target_w3, + tx_hash=tx_result.target_tx.tx_hash, + logger=self.logger, + ) + + return tx_result + + def fetch_lz_event_log(self, tx_result: BridgeTxResult, context: BridgeContext) -> LogReceipt: try: source_event = context.source_event.process_log(tx_result.source_tx.tx_receipt.logs[-1]) @@ -525,9 +524,10 @@ def fetch_lz_event_log(self, tx_result: BridgeTxResult, context: BridgeContext): self.logger.info( f"🔍 Listening for OFTReceived on [{tx_result.target_chain.name}] at {context.target_event.address}" ) + return wait_for_event(context.target_w3, filter_params, logger=self.logger) - def fetch_socket_event_log(self, tx_result: BridgeTxResult, context: BridgeContext): + def fetch_socket_event_log(self, tx_result: BridgeTxResult, context: BridgeContext) -> LogReceipt: try: source_event = context.source_event.process_log(tx_result.source_tx.tx_receipt.logs[-2]) @@ -548,9 +548,11 @@ def matching_message_id(log: AttributeDict) -> bool: self.logger.info( f"🔍 Listening for ExecutionSuccess on [{tx_result.target_chain.name}] at {context.target_event.address}" ) + return wait_for_event(context.target_w3, filter_params, condition=matching_message_id, logger=self.logger) - def _prepare_new_style_deposit(self, token_data: NonMintableTokenData, amount: int) -> dict: + def _prepare_new_style_deposit(self, token_data: NonMintableTokenData, amount: int) -> tuple[ContractFunction, int]: + vault_contract = _load_vault_contract(w3=self.remote_w3, token_data=token_data) connector = token_data.connectors[ChainID.DERIVE][TARGET_SPEED] fees = _get_min_fees(bridge_contract=vault_contract, connector=connector, token_data=token_data) @@ -562,9 +564,11 @@ def _prepare_new_style_deposit(self, token_data: NonMintableTokenData, amount: i extraData_=b"", options_=b"", ) + return func, fees - def _prepare_old_style_deposit(self, token_data: NonMintableTokenData, amount: int) -> dict: + def _prepare_old_style_deposit(self, token_data: NonMintableTokenData, amount: int) -> tuple[ContractFunction, int]: + vault_contract = _load_vault_contract(w3=self.remote_w3, token_data=token_data) connector = token_data.connectors[ChainID.DERIVE][TARGET_SPEED] fees = _get_min_fees(bridge_contract=vault_contract, connector=connector, token_data=token_data) @@ -576,9 +580,11 @@ def _prepare_old_style_deposit(self, token_data: NonMintableTokenData, amount: i gasLimit=MSG_GAS_LIMIT, connector=connector, ) + return func, fees - def _check_bridge_funds(self, token_data, connector: Address, amount: int): + def _check_bridge_funds(self, token_data, connector: Address, amount: int) -> None: + controller = _load_controller_contract(w3=self.derive_w3, token_data=token_data) if token_data.isNewBridge: deposit_hook = controller.functions.hook__().call() diff --git a/derive_client/clients/base_client.py b/derive_client/clients/base_client.py index 0a04567d..636cac25 100644 --- a/derive_client/clients/base_client.py +++ b/derive_client/clients/base_client.py @@ -163,7 +163,9 @@ def deposit_to_derive_result( amount = int(amount * 10 ** TOKEN_DECIMALS[UnderlyingCurrency[currency.name.upper()]]) client = BridgeClient(self.env, chain_id, account=self.signer, wallet=self.wallet, logger=self.logger) - return client.deposit(amount=amount, currency=currency) + prepared_tx = client.prepare_deposit(amount=amount, currency=currency) + + return client.submit_bridge_tx(prepared_tx) def deposit_to_derive( self, @@ -212,10 +214,9 @@ def withdraw_from_derive_result( amount = int(amount * 10 ** TOKEN_DECIMALS[UnderlyingCurrency[currency.name.upper()]]) client = BridgeClient(self.env, chain_id, account=self.signer, wallet=self.wallet, logger=self.logger) - # if currency == Currency.DRV: - # return client.withdraw(amount=amount, currency=currency) + prepared_tx = client.prepare_withdrawal(amount=amount, currency=currency) - return client.withdraw(amount=amount, currency=currency) + return client.submit_bridge_tx(prepared_tx=prepared_tx) def withdraw_from_derive( self, From 31a9cd62f65dedc6904351aafa9ca84cced58d0f Mon Sep 17 00:00:00 2001 From: zarathustra Date: Wed, 13 Aug 2025 13:08:52 +0200 Subject: [PATCH 030/101] refactor: no longer mutate BridgeTxResult internally --- derive_client/_bridge/client.py | 28 ++++++++++++++-------------- derive_client/data_types/models.py | 8 +++++--- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index e211d5e8..91461927 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -14,7 +14,7 @@ from web3.contract import Contract from web3.contract.contract import ContractFunction from web3.datastructures import AttributeDict -from web3.types import LogReceipt +from web3.types import LogReceipt, TxReceipt, HexBytes from derive_client._bridge.transaction import ensure_token_allowance, ensure_token_balance from derive_client.constants import ( @@ -305,9 +305,9 @@ def prepare_withdrawal(self, amount: int, currency: Currency) -> PreparedBridgeT def submit_bridge_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResult: tx_result = self.send_tx(prepared_tx=prepared_tx) - tx_result = self.confirm_source_tx(tx_result=tx_result) - tx_result = self.wait_for_target_event(tx_result=tx_result) - tx_result = self.confirm_target_tx(tx_result=tx_result) + tx_result.source_tx.tx_receipt = self.confirm_source_tx(tx_result=tx_result) + tx_result.target_tx = TxResult(tx_hash=self.wait_for_target_event(tx_result=tx_result)) + tx_result.target_tx.tx_receipt = self.confirm_target_tx(tx_result=tx_result) return tx_result @@ -462,20 +462,20 @@ def send_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResult: return tx_result - def confirm_source_tx(self, tx_result: BridgeTxResult) -> BridgeTxResult: + def confirm_source_tx(self, tx_result: BridgeTxResult) -> TxReceipt: context = self._get_context(tx_result) msg = "⏳ Checking source chain [%s] tx receipt for %s" self.logger.info(msg, tx_result.source_chain.name, tx_result.source_tx.tx_hash) - tx_result.source_tx.tx_receipt = wait_for_tx_finality( + tx_receipt = wait_for_tx_finality( w3=context.source_w3, tx_hash=tx_result.source_tx.tx_hash, logger=self.logger, ) - return tx_result + return tx_receipt - def wait_for_target_event(self, tx_result: BridgeTxResult) -> BridgeTxResult: + def wait_for_target_event(self, tx_result: BridgeTxResult) -> HexBytes: bridge_event_fetchers = { BridgeType.SOCKET: self.fetch_socket_event_log, @@ -486,23 +486,23 @@ def wait_for_target_event(self, tx_result: BridgeTxResult) -> BridgeTxResult: context = self._get_context(tx_result) event_log = fetch_event(tx_result, context) - tx_result.target_tx = TxResult(event_log["transactionHash"].to_0x_hex()) - self.logger.info(f"Target event tx_hash found: {tx_result.target_tx.tx_hash}") + tx_hash = event_log["transactionHash"] + self.logger.info(f"Target event tx_hash found: {tx_hash.to_0x_hex()}") - return tx_result + return tx_hash - def confirm_target_tx(self, tx_result: BridgeTxResult) -> BridgeTxResult: + def confirm_target_tx(self, tx_result: BridgeTxResult) -> TxReceipt: context = self._get_context(tx_result) msg = "⏳ Checking target chain [%s] tx receipt for %s" self.logger.info(msg, tx_result.target_chain.name, tx_result.target_tx.tx_hash) - tx_result.target_tx.tx_receipt = wait_for_tx_finality( + tx_receipt = wait_for_tx_finality( w3=context.target_w3, tx_hash=tx_result.target_tx.tx_hash, logger=self.logger, ) - return tx_result + return tx_receipt def fetch_lz_event_log(self, tx_result: BridgeTxResult, context: BridgeContext) -> LogReceipt: diff --git a/derive_client/data_types/models.py b/derive_client/data_types/models.py index b36636d6..921fa7ac 100644 --- a/derive_client/data_types/models.py +++ b/derive_client/data_types/models.py @@ -103,7 +103,7 @@ def _validate(cls, v: Any) -> SignedTransaction: class Address(str): @classmethod def __get_pydantic_core_schema__(cls, _source, _handler: GetCoreSchemaHandler) -> core_schema.CoreSchema: - return core_schema.no_info_before_validator_function(cls._validate, core_schema.str_schema()) + return core_schema.no_info_before_validator_function(cls._validate, core_schema.any_schema()) @classmethod def __get_pydantic_json_schema__(cls, _schema, _handler: GetJsonSchemaHandler) -> dict: @@ -126,9 +126,11 @@ def __get_pydantic_json_schema__(cls, _schema, _handler: GetJsonSchemaHandler): return {"type": "string", "format": "ethereum-tx-hash"} @classmethod - def _validate(cls, v: str) -> str: + def _validate(cls, v: str | HexBytes) -> str: + if isinstance(v, HexBytes): + v = v.to_0x_hex() if not isinstance(v, str): - raise TypeError("Expected a string for TxHash") + raise TypeError("Expected a string or HexBytes for TxHash") if not is_0x_prefixed(v) or not is_hex(v) or len(v) != 66: raise ValueError(f"Invalid Ethereum transaction hash: {v}") return v From a62fa0bc38daf7a7d30b5caae026fcf3102a0a2f Mon Sep 17 00:00:00 2001 From: zarathustra Date: Wed, 13 Aug 2025 13:13:53 +0200 Subject: [PATCH 031/101] refactor: _load_deposit_helper --- derive_client/_bridge/client.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index 91461927..27a0a19d 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -153,18 +153,15 @@ def private_key(self) -> str: return self.account._private_key def _load_deposit_helper(self) -> Contract: - address = ( - self.config.contracts.DEPOSIT_WRAPPER - if self.remote_chain_id - not in [ - ChainID.ARBITRUM, - ChainID.OPTIMISM, - ] - else getattr( - self.config.contracts, - f"{self.remote_chain_id.name}_DEPOSIT_WRAPPER", - ) - ) + + match self.remote_chain_id: + case ChainID.ARBITRUM: + address = self.config.contracts.ARBITRUM_DEPOSIT_WRAPPER + case ChainID.OPTIMISM: + address = self.config.contracts.OPTIMISM_DEPOSIT_WRAPPER + case _: + address = self.config.contracts.DEPOSIT_WRAPPER + abi = json.loads(DEPOSIT_HELPER_ABI_PATH.read_text()) return get_contract(w3=self.remote_w3, address=address, abi=abi) @@ -220,12 +217,14 @@ def _make_bridge_context( raise BridgeRouteError(f"Unsupported bridge_type={bridge_type} for currency={currency}.") def _get_context(self, state: PreparedBridgeTx | BridgeTxResult) -> BridgeContext: + direction = "withdraw" if state.source_chain == ChainID.DERIVE else "deposit" context = self._make_bridge_context( direction=direction, bridge_type=state.bridge, currency=state.currency, ) + return context def _resolve_socket_route( @@ -233,6 +232,7 @@ def _resolve_socket_route( direction: Literal["deposit", "withdraw"], currency: Currency, ) -> tuple[MintableTokenData | NonMintableTokenData, Address]: + is_deposit = direction == "deposit" src_chain, tgt_chain = ( (self.remote_chain_id, ChainID.DERIVE) if is_deposit else (ChainID.DERIVE, self.remote_chain_id) From 76a50b9f8bada07777e16857cac2f42522cafcb0 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Wed, 13 Aug 2025 13:26:11 +0200 Subject: [PATCH 032/101] refactor: replace use of string Literal with Direction enum --- derive_client/_bridge/client.py | 37 +++++++++++++++++----------- derive_client/data_types/__init__.py | 2 ++ derive_client/data_types/enums.py | 5 ++++ 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index 27a0a19d..e761309b 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -7,14 +7,13 @@ import functools import json from logging import Logger -from typing import Literal from eth_account import Account from web3 import Web3 from web3.contract import Contract from web3.contract.contract import ContractFunction from web3.datastructures import AttributeDict -from web3.types import LogReceipt, TxReceipt, HexBytes +from web3.types import HexBytes, LogReceipt, TxReceipt from derive_client._bridge.transaction import ensure_token_allowance, ensure_token_balance from derive_client.constants import ( @@ -46,6 +45,7 @@ ChainID, Currency, DeriveTokenAddresses, + Direction, Environment, LayerZeroChainIDv2, MintableTokenData, @@ -173,12 +173,12 @@ def _load_withdraw_wrapper(self) -> Contract: @functools.lru_cache def _make_bridge_context( self, - direction: Literal["deposit", "withdraw"], + direction: Direction, bridge_type: BridgeType, currency: Currency, ) -> BridgeContext: - is_deposit = direction == "deposit" + is_deposit = direction == Direction.DEPOSIT src_w3, tgt_w3 = (self.remote_w3, self.derive_w3) if is_deposit else (self.derive_w3, self.remote_w3) src_chain, tgt_chain = ( (self.remote_chain_id, ChainID.DERIVE) if is_deposit else (ChainID.DERIVE, self.remote_chain_id) @@ -218,7 +218,7 @@ def _make_bridge_context( def _get_context(self, state: PreparedBridgeTx | BridgeTxResult) -> BridgeContext: - direction = "withdraw" if state.source_chain == ChainID.DERIVE else "deposit" + direction = Direction.WITHDRAW if state.source_chain == ChainID.DERIVE else Direction.DEPOSIT context = self._make_bridge_context( direction=direction, bridge_type=state.bridge, @@ -229,13 +229,14 @@ def _get_context(self, state: PreparedBridgeTx | BridgeTxResult) -> BridgeContex def _resolve_socket_route( self, - direction: Literal["deposit", "withdraw"], + direction: Direction, currency: Currency, ) -> tuple[MintableTokenData | NonMintableTokenData, Address]: - is_deposit = direction == "deposit" src_chain, tgt_chain = ( - (self.remote_chain_id, ChainID.DERIVE) if is_deposit else (ChainID.DERIVE, self.remote_chain_id) + (self.remote_chain_id, ChainID.DERIVE) + if direction == Direction.DEPOSIT + else (ChainID.DERIVE, self.remote_chain_id) ) if (src_token_data := self.derive_addresses.chains[src_chain].get(currency)) is None: @@ -313,8 +314,10 @@ def submit_bridge_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResult: def prepare_socket_deposit(self, amount: int, currency: Currency) -> PreparedBridgeTx: - token_data, _connector = self._resolve_socket_route("deposit", currency=currency) - context = self._make_bridge_context("deposit", bridge_type=BridgeType.SOCKET, currency=currency) + direction = Direction.DEPOSIT + bridge_type = BridgeType.SOCKET + token_data, _connector = self._resolve_socket_route(direction, currency=currency) + context = self._make_bridge_context(direction, bridge_type=bridge_type, currency=currency) spender = token_data.Vault if token_data.isNewBridge else self.deposit_helper.address ensure_token_balance(context.source_token, self.owner, amount) @@ -339,8 +342,10 @@ def prepare_socket_deposit(self, amount: int, currency: Currency) -> PreparedBri def prepare_socket_withdrawal(self, amount: int, currency: Currency) -> PreparedBridgeTx: - token_data, connector = self._resolve_socket_route("withdraw", currency=currency) - context = self._make_bridge_context("withdraw", bridge_type=BridgeType.SOCKET, currency=currency) + direction = Direction.WITHDRAW + bridge_type = BridgeType.SOCKET + token_data, connector = self._resolve_socket_route(direction, currency=currency) + context = self._make_bridge_context(direction, bridge_type=bridge_type, currency=currency) ensure_token_balance(context.source_token, self.wallet, amount) self._check_bridge_funds(token_data, connector, amount) @@ -369,7 +374,9 @@ def prepare_socket_withdrawal(self, amount: int, currency: Currency) -> Prepared def prepare_layerzero_deposit(self, amount: int, currency: Currency) -> PreparedBridgeTx: - context = self._make_bridge_context("deposit", bridge_type=BridgeType.LAYERZERO, currency=currency) + direction = Direction.DEPOSIT + bridge_type = BridgeType.LAYERZERO + context = self._make_bridge_context(direction, bridge_type=bridge_type, currency=currency) # check allowance, if needed approve ensure_token_balance(context.source_token, self.owner, amount) @@ -409,7 +416,9 @@ def prepare_layerzero_deposit(self, amount: int, currency: Currency) -> Prepared def prepare_layerzero_withdrawal(self, amount: int, currency: Currency) -> PreparedBridgeTx: - context = self._make_bridge_context("withdraw", bridge_type=BridgeType.LAYERZERO, currency=currency) + direction = Direction.WITHDRAW + bridge_type = BridgeType.LAYERZERO + context = self._make_bridge_context(direction, bridge_type=bridge_type, currency=currency) abi = json.loads(LYRA_OFT_WITHDRAW_WRAPPER_ABI_PATH.read_text()) withdraw_wrapper = get_contract(context.source_w3, LYRA_OFT_WITHDRAW_WRAPPER_ADDRESS, abi=abi) diff --git a/derive_client/data_types/__init__.py b/derive_client/data_types/__init__.py index 9a4b2014..a658e727 100644 --- a/derive_client/data_types/__init__.py +++ b/derive_client/data_types/__init__.py @@ -9,6 +9,7 @@ DeriveJSONRPCErrorCode, DeriveTokenAddresses, DeriveTxStatus, + Direction, Environment, EthereumJSONRPCErrorCode, InstrumentType, @@ -48,6 +49,7 @@ __all__ = [ "TxStatus", "DeriveTxStatus", + "Direction", "BridgeType", "BridgeContext", "BridgeTxResult", diff --git a/derive_client/data_types/enums.py b/derive_client/data_types/enums.py index edd54b31..e8cbefe4 100644 --- a/derive_client/data_types/enums.py +++ b/derive_client/data_types/enums.py @@ -25,6 +25,11 @@ class BridgeType(Enum): LAYERZERO = "layerzero" +class Direction(Enum): + DEPOSIT = "deposit" + WITHDRAW = "withdraw" + + class ChainID(IntEnum): ETH = 1 OPTIMISM = 10 From df1f1d0610e34e6752bf40471619fa72befc6077 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Wed, 13 Aug 2025 13:56:21 +0200 Subject: [PATCH 033/101] feat: PartialBridgeResult --- derive_client/_bridge/client.py | 10 +++++++--- derive_client/exceptions.py | 8 ++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index e761309b..266ac5c2 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -59,6 +59,7 @@ BridgePrimarySignerRequiredError, BridgeRouteError, DrvWithdrawAmountBelowFee, + PartialBridgeResult, ) from derive_client.utils import ( build_standard_transaction, @@ -306,9 +307,12 @@ def prepare_withdrawal(self, amount: int, currency: Currency) -> PreparedBridgeT def submit_bridge_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResult: tx_result = self.send_tx(prepared_tx=prepared_tx) - tx_result.source_tx.tx_receipt = self.confirm_source_tx(tx_result=tx_result) - tx_result.target_tx = TxResult(tx_hash=self.wait_for_target_event(tx_result=tx_result)) - tx_result.target_tx.tx_receipt = self.confirm_target_tx(tx_result=tx_result) + try: + tx_result.source_tx.tx_receipt = self.confirm_source_tx(tx_result=tx_result) + tx_result.target_tx = TxResult(tx_hash=self.wait_for_target_event(tx_result=tx_result)) + tx_result.target_tx.tx_receipt = self.confirm_target_tx(tx_result=tx_result) + except Exception as e: + raise PartialBridgeResult(f"Bridge pipeline failed: {e}", tx_result=tx_result) from e return tx_result diff --git a/derive_client/exceptions.py b/derive_client/exceptions.py index 5e8b84da..f18b821b 100644 --- a/derive_client/exceptions.py +++ b/derive_client/exceptions.py @@ -87,3 +87,11 @@ class TxPendingTimeout(Exception): class TransactionDropped(Exception): """Raised when the transaction the transaction is no longer in the mempool, likely dropped.""" + + +class PartialBridgeResult(Exception): + """Raised after submission when the bridge pipeline fails""" + + def __init__(self, message: str, *, tx_result: "BridgeTxResult"): + super().__init__(message) + self.tx_result = tx_result From 43a1af73dbc1d21d4ea0450a176afa3cff0e5e7b Mon Sep 17 00:00:00 2001 From: zarathustra Date: Wed, 13 Aug 2025 14:17:51 +0200 Subject: [PATCH 034/101] feat: introduce poll_bridge_progress on BridgeClient --- derive_client/_bridge/client.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index 266ac5c2..503dcc0f 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -306,7 +306,12 @@ def prepare_withdrawal(self, amount: int, currency: Currency) -> PreparedBridgeT def submit_bridge_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResult: - tx_result = self.send_tx(prepared_tx=prepared_tx) + tx_result = self.send_bridge_tx(prepared_tx=prepared_tx) + + return self.poll_bridge_progress(tx_result) + + def poll_bridge_progress(self, tx_result: BridgeTxResult) -> BridgeTxResult: + try: tx_result.source_tx.tx_receipt = self.confirm_source_tx(tx_result=tx_result) tx_result.target_tx = TxResult(tx_hash=self.wait_for_target_event(tx_result=tx_result)) @@ -452,7 +457,7 @@ def prepare_layerzero_withdrawal(self, amount: int, currency: Currency) -> Prepa return prepared_tx - def send_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResult: + def send_bridge_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResult: context = self._get_context(prepared_tx) From 721ba794b6edd8483dc3cf76258caf49e06eccc8 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Wed, 13 Aug 2025 14:28:37 +0200 Subject: [PATCH 035/101] fix: remove poll_bridge_progress call from submit_bridge_tx --- derive_client/_bridge/client.py | 2 +- derive_client/clients/base_client.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index 503dcc0f..5facfeda 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -308,7 +308,7 @@ def submit_bridge_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResult: tx_result = self.send_bridge_tx(prepared_tx=prepared_tx) - return self.poll_bridge_progress(tx_result) + return tx_result def poll_bridge_progress(self, tx_result: BridgeTxResult) -> BridgeTxResult: diff --git a/derive_client/clients/base_client.py b/derive_client/clients/base_client.py index 636cac25..821506aa 100644 --- a/derive_client/clients/base_client.py +++ b/derive_client/clients/base_client.py @@ -164,8 +164,9 @@ def deposit_to_derive_result( client = BridgeClient(self.env, chain_id, account=self.signer, wallet=self.wallet, logger=self.logger) prepared_tx = client.prepare_deposit(amount=amount, currency=currency) + tx_result = client.submit_bridge_tx(prepared_tx) - return client.submit_bridge_tx(prepared_tx) + return client.poll_bridge_progress(tx_result=tx_result) def deposit_to_derive( self, @@ -215,8 +216,9 @@ def withdraw_from_derive_result( client = BridgeClient(self.env, chain_id, account=self.signer, wallet=self.wallet, logger=self.logger) prepared_tx = client.prepare_withdrawal(amount=amount, currency=currency) + tx_result = client.submit_bridge_tx(prepared_tx=prepared_tx) - return client.submit_bridge_tx(prepared_tx=prepared_tx) + return client.poll_bridge_progress(tx_result=tx_result) def withdraw_from_derive( self, From 351c27cb83ddf85032d48489f2f15bace54a945c Mon Sep 17 00:00:00 2001 From: zarathustra Date: Wed, 13 Aug 2025 15:19:07 +0200 Subject: [PATCH 036/101] chore: make bridge methods private --- derive_client/_bridge/client.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index 5facfeda..7cce88d6 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -289,18 +289,18 @@ def _prepare_tx( def prepare_deposit(self, amount: int, currency: Currency) -> PreparedBridgeTx: if currency == Currency.DRV: - prepared_tx = self.prepare_layerzero_deposit(amount=amount, currency=currency) + prepared_tx = self._prepare_layerzero_deposit(amount=amount, currency=currency) else: - prepared_tx = self.prepare_socket_deposit(amount=amount, currency=currency) + prepared_tx = self._prepare_socket_deposit(amount=amount, currency=currency) return prepared_tx def prepare_withdrawal(self, amount: int, currency: Currency) -> PreparedBridgeTx: if currency == Currency.DRV: - prepared_tx = self.prepare_layerzero_withdrawal(amount=amount, currency=currency) + prepared_tx = self._prepare_layerzero_withdrawal(amount=amount, currency=currency) else: - prepared_tx = self.prepare_socket_withdrawal(amount=amount, currency=currency) + prepared_tx = self._prepare_socket_withdrawal(amount=amount, currency=currency) return prepared_tx @@ -321,7 +321,7 @@ def poll_bridge_progress(self, tx_result: BridgeTxResult) -> BridgeTxResult: return tx_result - def prepare_socket_deposit(self, amount: int, currency: Currency) -> PreparedBridgeTx: + def _prepare_socket_deposit(self, amount: int, currency: Currency) -> PreparedBridgeTx: direction = Direction.DEPOSIT bridge_type = BridgeType.SOCKET @@ -349,7 +349,7 @@ def prepare_socket_deposit(self, amount: int, currency: Currency) -> PreparedBri return prepared_tx - def prepare_socket_withdrawal(self, amount: int, currency: Currency) -> PreparedBridgeTx: + def _prepare_socket_withdrawal(self, amount: int, currency: Currency) -> PreparedBridgeTx: direction = Direction.WITHDRAW bridge_type = BridgeType.SOCKET @@ -381,7 +381,7 @@ def prepare_socket_withdrawal(self, amount: int, currency: Currency) -> Prepared return prepared_tx - def prepare_layerzero_deposit(self, amount: int, currency: Currency) -> PreparedBridgeTx: + def _prepare_layerzero_deposit(self, amount: int, currency: Currency) -> PreparedBridgeTx: direction = Direction.DEPOSIT bridge_type = BridgeType.LAYERZERO @@ -423,7 +423,7 @@ def prepare_layerzero_deposit(self, amount: int, currency: Currency) -> Prepared return prepared_tx - def prepare_layerzero_withdrawal(self, amount: int, currency: Currency) -> PreparedBridgeTx: + def _prepare_layerzero_withdrawal(self, amount: int, currency: Currency) -> PreparedBridgeTx: direction = Direction.WITHDRAW bridge_type = BridgeType.LAYERZERO @@ -496,8 +496,8 @@ def confirm_source_tx(self, tx_result: BridgeTxResult) -> TxReceipt: def wait_for_target_event(self, tx_result: BridgeTxResult) -> HexBytes: bridge_event_fetchers = { - BridgeType.SOCKET: self.fetch_socket_event_log, - BridgeType.LAYERZERO: self.fetch_lz_event_log, + BridgeType.SOCKET: self._fetch_socket_event_log, + BridgeType.LAYERZERO: self._fetch_lz_event_log, } if (fetch_event := bridge_event_fetchers.get(tx_result.bridge)) is None: raise BridgeRouteError(f"Invalid bridge_type: {tx_result.bridge}") @@ -522,7 +522,7 @@ def confirm_target_tx(self, tx_result: BridgeTxResult) -> TxReceipt: return tx_receipt - def fetch_lz_event_log(self, tx_result: BridgeTxResult, context: BridgeContext) -> LogReceipt: + def _fetch_lz_event_log(self, tx_result: BridgeTxResult, context: BridgeContext) -> LogReceipt: try: source_event = context.source_event.process_log(tx_result.source_tx.tx_receipt.logs[-1]) @@ -545,7 +545,7 @@ def fetch_lz_event_log(self, tx_result: BridgeTxResult, context: BridgeContext) return wait_for_event(context.target_w3, filter_params, logger=self.logger) - def fetch_socket_event_log(self, tx_result: BridgeTxResult, context: BridgeContext) -> LogReceipt: + def _fetch_socket_event_log(self, tx_result: BridgeTxResult, context: BridgeContext) -> LogReceipt: try: source_event = context.source_event.process_log(tx_result.source_tx.tx_receipt.logs[-2]) From 4e60d1d8dbf3d8051991396e469ea5f9734f3336 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Wed, 13 Aug 2025 15:22:10 +0200 Subject: [PATCH 037/101] chore: remove wait_for_tx_receipt --- derive_client/utils/w3.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/derive_client/utils/w3.py b/derive_client/utils/w3.py index f876d029..bc0b0a14 100644 --- a/derive_client/utils/w3.py +++ b/derive_client/utils/w3.py @@ -221,21 +221,6 @@ def build_standard_transaction( return simulate_tx(w3, tx, account) -def wait_for_tx_receipt(w3: Web3, tx_hash: str, timeout=120, poll_interval=1) -> AttributeDict: - start_time = time.monotonic() - - while True: - try: - receipt = AttributeDict(w3.eth.get_transaction_receipt(tx_hash)) - except Exception: - receipt = None - if receipt is not None: - return receipt - if time.monotonic() - start_time > timeout: - raise TimeoutError("Timed out waiting for transaction receipt.") - time.sleep(poll_interval) - - def wait_for_tx_finality( w3: Web3, tx_hash: str, From 7629743b5380947bc5dbc357d7614589f32c389a Mon Sep 17 00:00:00 2001 From: zarathustra Date: Wed, 13 Aug 2025 15:42:54 +0200 Subject: [PATCH 038/101] chore: make deposit_helper and withdraw_wrapper loading lazy --- derive_client/_bridge/client.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index 7cce88d6..9a8b2ed4 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -120,8 +120,6 @@ def __init__(self, env: Environment, chain_id: ChainID, account: Account, wallet self.derive_w3 = get_w3_connection(chain_id=ChainID.DERIVE, logger=logger) self.remote_w3 = get_w3_connection(chain_id=chain_id, logger=logger) self.account = account - self.withdraw_wrapper = self._load_withdraw_wrapper() - self.deposit_helper = self._load_deposit_helper() self.derive_addresses = get_prod_derive_addresses() self.light_account = _load_light_account(w3=self.derive_w3, wallet=wallet) self.logger = logger @@ -153,7 +151,8 @@ def private_key(self) -> str: """Private key of the owner (EOA) of the smart contract funding account.""" return self.account._private_key - def _load_deposit_helper(self) -> Contract: + @functools.cached_property + def deposit_helper(self) -> Contract: match self.remote_chain_id: case ChainID.ARBITRUM: @@ -166,7 +165,8 @@ def _load_deposit_helper(self) -> Contract: abi = json.loads(DEPOSIT_HELPER_ABI_PATH.read_text()) return get_contract(w3=self.remote_w3, address=address, abi=abi) - def _load_withdraw_wrapper(self) -> Contract: + @functools.cached_property + def withdraw_wrapper(self) -> Contract: address = self.config.contracts.WITHDRAW_WRAPPER_V2 abi = json.loads(WITHDRAW_WRAPPER_V2_ABI_PATH.read_text()) return get_contract(w3=self.derive_w3, address=address, abi=abi) From c39ddf5d14b4270989059122c02e77488ea8d857 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Wed, 13 Aug 2025 15:44:32 +0200 Subject: [PATCH 039/101] chore: increase finality_blocks default 12 -> 120 --- derive_client/utils/w3.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/derive_client/utils/w3.py b/derive_client/utils/w3.py index bc0b0a14..caaaa41d 100644 --- a/derive_client/utils/w3.py +++ b/derive_client/utils/w3.py @@ -225,7 +225,7 @@ def wait_for_tx_finality( w3: Web3, tx_hash: str, logger: Logger, - finality_blocks: int = 10, + finality_blocks: int = 120, timeout: float = 300.0, poll_interval: float = 1.0, ) -> AttributeDict: From 9c426e3def5a4d20091f93919050824ec8c32879 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Wed, 13 Aug 2025 16:19:03 +0200 Subject: [PATCH 040/101] feat: expose original exception via PartialBridgeResult.cause --- derive_client/_bridge/client.py | 2 +- derive_client/exceptions.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index 9a8b2ed4..cf19e3da 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -317,7 +317,7 @@ def poll_bridge_progress(self, tx_result: BridgeTxResult) -> BridgeTxResult: tx_result.target_tx = TxResult(tx_hash=self.wait_for_target_event(tx_result=tx_result)) tx_result.target_tx.tx_receipt = self.confirm_target_tx(tx_result=tx_result) except Exception as e: - raise PartialBridgeResult(f"Bridge pipeline failed: {e}", tx_result=tx_result) from e + raise PartialBridgeResult("Bridge pipeline failed", tx_result=tx_result) from e return tx_result diff --git a/derive_client/exceptions.py b/derive_client/exceptions.py index f18b821b..bbe689fe 100644 --- a/derive_client/exceptions.py +++ b/derive_client/exceptions.py @@ -95,3 +95,8 @@ class PartialBridgeResult(Exception): def __init__(self, message: str, *, tx_result: "BridgeTxResult"): super().__init__(message) self.tx_result = tx_result + + @property + def cause(self) -> Exception | None: + """Provides access to the orignal Exception.""" + return self.__cause__ From 7a6fd1f13f0650f055900b6fe54c7124a2eec572 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Thu, 14 Aug 2025 10:30:58 +0200 Subject: [PATCH 041/101] feat: turn BridgeClient from sync to async --- derive_client/_bridge/client.py | 127 ++++++++++++++------------- derive_client/_bridge/transaction.py | 18 ++-- derive_client/clients/base_client.py | 27 ++++-- derive_client/constants.py | 2 +- derive_client/data_types/models.py | 33 ++++--- derive_client/utils/retry.py | 11 +-- derive_client/utils/w3.py | 87 +++++++++--------- 7 files changed, 167 insertions(+), 138 deletions(-) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index cf19e3da..e6c54995 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -109,7 +109,7 @@ def _get_min_fees( if token_data.isNewBridge: params["payloadSize_"] = PAYLOAD_SIZE - return bridge_contract.functions.getMinFees(**params).call() + return bridge_contract.functions.getMinFees(**params) class BridgeClient: @@ -123,6 +123,8 @@ def __init__(self, env: Environment, chain_id: ChainID, account: Account, wallet self.derive_addresses = get_prod_derive_addresses() self.light_account = _load_light_account(w3=self.derive_w3, wallet=wallet) self.logger = logger + self.owner = self.account.address + self.remote_chain_id = chain_id if self.owner != self.account.address: raise BridgePrimarySignerRequiredError( "Bridging disabled for secondary session-key signers: old-style assets " @@ -132,19 +134,19 @@ def __init__(self, env: Environment, chain_id: ChainID, account: Account, wallet "primary wallet owner." ) - @property - def remote_chain_id(self) -> ChainID: - return ChainID(self.remote_w3.eth.chain_id) + # @property + # def remote_chain_id(self) -> ChainID: + # return ChainID(self.remote_w3.eth.chain_id) @property def wallet(self) -> Address: """Smart contract funding wallet.""" return self.light_account.address - @functools.cached_property - def owner(self) -> Address: - """Owner of smart contract funding wallet, must be the same as self.account.address.""" - return self.light_account.functions.owner().call() + # @functools.cached_property + # def owner(self) -> Address: + # """Owner of smart contract funding wallet, must be the same as self.account.address.""" + # return await self.light_account.functions.owner().call() @property def private_key(self) -> str: @@ -195,7 +197,8 @@ def _make_bridge_context( src = get_contract(src_w3, src_addr, abi=src_abi) tgt = get_contract(tgt_w3, tgt_addr, abi=tgt_abi) src_event, tgt_event = src.events.OFTSent(), tgt.events.OFTReceived() - return BridgeContext(src_w3, tgt_w3, src, src_event, tgt_event) + context = BridgeContext(src_w3, tgt_w3, src, src_event, tgt_event, src_chain, tgt_chain) + return context elif bridge_type == BridgeType.SOCKET and currency is not Currency.DRV: erc20_abi = json.loads(ERC20_ABI_PATH.read_text()) @@ -213,7 +216,8 @@ def _make_bridge_context( src_socket = get_contract(src_w3, address=src_addr, abi=socket_abi) tgt_socket = get_contract(tgt_w3, address=tgt_addr, abi=socket_abi) src_event, tgt_event = src_socket.events.MessageOutbound(), tgt_socket.events.ExecutionSuccess() - return BridgeContext(src_w3, tgt_w3, token_contract, src_event, tgt_event) + context = BridgeContext(src_w3, tgt_w3, token_contract, src_event, tgt_event, src_chain, tgt_chain) + return context raise BridgeRouteError(f"Unsupported bridge_type={bridge_type} for currency={currency}.") @@ -256,7 +260,7 @@ def _resolve_socket_route( return src_token_data, src_token_data.connectors[tgt_chain][TARGET_SPEED] - def _prepare_tx( + async def _prepare_tx( self, func: ContractFunction, value: int, @@ -264,7 +268,7 @@ def _prepare_tx( context: BridgeContext, ) -> PreparedBridgeTx: - tx = build_standard_transaction(func=func, account=self.account, w3=context.source_w3, value=value) + tx = await build_standard_transaction(func=func, account=self.account, w3=context.source_w3, value=value) signed_tx = sign_tx(w3=context.source_w3, tx=tx, private_key=self.private_key) tx_details = BridgeTxDetails( @@ -286,42 +290,42 @@ def _prepare_tx( return prepared_tx - def prepare_deposit(self, amount: int, currency: Currency) -> PreparedBridgeTx: + async def prepare_deposit(self, amount: int, currency: Currency) -> PreparedBridgeTx: if currency == Currency.DRV: - prepared_tx = self._prepare_layerzero_deposit(amount=amount, currency=currency) + prepared_tx = await self._prepare_layerzero_deposit(amount=amount, currency=currency) else: - prepared_tx = self._prepare_socket_deposit(amount=amount, currency=currency) + prepared_tx = await self._prepare_socket_deposit(amount=amount, currency=currency) return prepared_tx - def prepare_withdrawal(self, amount: int, currency: Currency) -> PreparedBridgeTx: + async def prepare_withdrawal(self, amount: int, currency: Currency) -> PreparedBridgeTx: if currency == Currency.DRV: - prepared_tx = self._prepare_layerzero_withdrawal(amount=amount, currency=currency) + prepared_tx = await self._prepare_layerzero_withdrawal(amount=amount, currency=currency) else: - prepared_tx = self._prepare_socket_withdrawal(amount=amount, currency=currency) + prepared_tx = await self._prepare_socket_withdrawal(amount=amount, currency=currency) return prepared_tx - def submit_bridge_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResult: + async def submit_bridge_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResult: - tx_result = self.send_bridge_tx(prepared_tx=prepared_tx) + tx_result = await self.send_bridge_tx(prepared_tx=prepared_tx) return tx_result - def poll_bridge_progress(self, tx_result: BridgeTxResult) -> BridgeTxResult: + async def poll_bridge_progress(self, tx_result: BridgeTxResult) -> BridgeTxResult: try: - tx_result.source_tx.tx_receipt = self.confirm_source_tx(tx_result=tx_result) - tx_result.target_tx = TxResult(tx_hash=self.wait_for_target_event(tx_result=tx_result)) - tx_result.target_tx.tx_receipt = self.confirm_target_tx(tx_result=tx_result) + tx_result.source_tx.tx_receipt = await self.confirm_source_tx(tx_result=tx_result) + tx_result.target_tx = TxResult(tx_hash=await self.wait_for_target_event(tx_result=tx_result)) + tx_result.target_tx.tx_receipt = await self.confirm_target_tx(tx_result=tx_result) except Exception as e: raise PartialBridgeResult("Bridge pipeline failed", tx_result=tx_result) from e return tx_result - def _prepare_socket_deposit(self, amount: int, currency: Currency) -> PreparedBridgeTx: + async def _prepare_socket_deposit(self, amount: int, currency: Currency) -> PreparedBridgeTx: direction = Direction.DEPOSIT bridge_type = BridgeType.SOCKET @@ -329,8 +333,8 @@ def _prepare_socket_deposit(self, amount: int, currency: Currency) -> PreparedBr context = self._make_bridge_context(direction, bridge_type=bridge_type, currency=currency) spender = token_data.Vault if token_data.isNewBridge else self.deposit_helper.address - ensure_token_balance(context.source_token, self.owner, amount) - ensure_token_allowance( + await ensure_token_balance(context.source_token, self.owner, amount) + await ensure_token_allowance( w3=context.source_w3, token_contract=context.source_token, owner=self.owner, @@ -341,23 +345,24 @@ def _prepare_socket_deposit(self, amount: int, currency: Currency) -> PreparedBr ) if token_data.isNewBridge: - func, fees = self._prepare_new_style_deposit(token_data, amount) + func, fees_func = self._prepare_new_style_deposit(token_data, amount) else: - func, fees = self._prepare_old_style_deposit(token_data, amount) + func, fees_func = self._prepare_old_style_deposit(token_data, amount) - prepared_tx = self._prepare_tx(func=func, value=fees + 1, currency=currency, context=context) + fees = await fees_func.call() + prepared_tx = await self._prepare_tx(func=func, value=fees + 1, currency=currency, context=context) return prepared_tx - def _prepare_socket_withdrawal(self, amount: int, currency: Currency) -> PreparedBridgeTx: + async def _prepare_socket_withdrawal(self, amount: int, currency: Currency) -> PreparedBridgeTx: direction = Direction.WITHDRAW bridge_type = BridgeType.SOCKET token_data, connector = self._resolve_socket_route(direction, currency=currency) context = self._make_bridge_context(direction, bridge_type=bridge_type, currency=currency) - ensure_token_balance(context.source_token, self.wallet, amount) - self._check_bridge_funds(token_data, connector, amount) + # ensure_token_balance(context.source_token, self.wallet, amount) + # self._check_bridge_funds(token_data, connector, amount) kwargs = { "token": context.source_token.address, @@ -377,19 +382,19 @@ def _prepare_socket_withdrawal(self, amount: int, currency: Currency) -> Prepare dest=[context.source_token.address, self.withdraw_wrapper.address], func=[approve_data, bridge_data], ) - prepared_tx = self._prepare_tx(func=func, value=0, currency=currency, context=context) + prepared_tx = await self._prepare_tx(func=func, value=0, currency=currency, context=context) return prepared_tx - def _prepare_layerzero_deposit(self, amount: int, currency: Currency) -> PreparedBridgeTx: + async def _prepare_layerzero_deposit(self, amount: int, currency: Currency) -> PreparedBridgeTx: direction = Direction.DEPOSIT bridge_type = BridgeType.LAYERZERO context = self._make_bridge_context(direction, bridge_type=bridge_type, currency=currency) # check allowance, if needed approve - ensure_token_balance(context.source_token, self.owner, amount) - ensure_token_allowance( + await ensure_token_balance(context.source_token, self.owner, amount) + await ensure_token_allowance( w3=context.source_w3, token_contract=context.source_token, owner=self.owner, @@ -414,16 +419,16 @@ def _prepare_layerzero_deposit(self, amount: int, currency: Currency) -> Prepare pay_in_lz_token = False send_params = tuple(kwargs.values()) - fees = context.source_token.functions.quoteSend(send_params, pay_in_lz_token).call() + fees = await context.source_token.functions.quoteSend(send_params, pay_in_lz_token).call() native_fee, lz_token_fee = fees refund_address = self.owner - func = context.source_token.functions.send(send_params, fees, refund_address) - prepared_tx = self._prepare_tx(func=func, value=native_fee, currency=currency, context=context) + func = await context.source_token.functions.send(send_params, fees, refund_address) + prepared_tx = await self._prepare_tx(func=func, value=native_fee, currency=currency, context=context) return prepared_tx - def _prepare_layerzero_withdrawal(self, amount: int, currency: Currency) -> PreparedBridgeTx: + async def _prepare_layerzero_withdrawal(self, amount: int, currency: Currency) -> PreparedBridgeTx: direction = Direction.WITHDRAW bridge_type = BridgeType.LAYERZERO @@ -432,10 +437,10 @@ def _prepare_layerzero_withdrawal(self, amount: int, currency: Currency) -> Prep abi = json.loads(LYRA_OFT_WITHDRAW_WRAPPER_ABI_PATH.read_text()) withdraw_wrapper = get_contract(context.source_w3, LYRA_OFT_WITHDRAW_WRAPPER_ADDRESS, abi=abi) - ensure_token_balance(context.source_token, self.wallet, amount) + await ensure_token_balance(context.source_token, self.wallet, amount) destEID = LayerZeroChainIDv2[context.target_chain.name] - fee = withdraw_wrapper.functions.getFeeInToken(context.source_token.address, amount, destEID).call() + fee = await withdraw_wrapper.functions.getFeeInToken(context.source_token.address, amount, destEID).call() if amount < fee: raise DrvWithdrawAmountBelowFee(f"Withdraw amount < fee: {amount} < {fee} ({(fee / amount * 100):.2f}%)") @@ -453,19 +458,19 @@ def _prepare_layerzero_withdrawal(self, amount: int, currency: Currency) -> Prep dest=[context.source_token.address, withdraw_wrapper.address], func=[approve_data, bridge_data], ) - prepared_tx = self._prepare_tx(func=func, value=0, currency=currency, context=context) + prepared_tx = await self._prepare_tx(func=func, value=0, currency=currency, context=context) return prepared_tx - def send_bridge_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResult: + async def send_bridge_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResult: context = self._get_context(prepared_tx) # record on target chain where we should start polling - target_from_block = context.target_w3.eth.block_number + target_from_block = await context.target_w3.eth.block_number signed_tx = prepared_tx.tx_details.signed_tx - tx_hash = send_tx(w3=context.source_w3, signed_tx=signed_tx) + tx_hash = await send_tx(w3=context.source_w3, signed_tx=signed_tx) source_tx = TxResult(tx_hash=tx_hash) tx_result = BridgeTxResult( @@ -480,12 +485,12 @@ def send_bridge_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResult: return tx_result - def confirm_source_tx(self, tx_result: BridgeTxResult) -> TxReceipt: + async def confirm_source_tx(self, tx_result: BridgeTxResult) -> TxReceipt: context = self._get_context(tx_result) msg = "⏳ Checking source chain [%s] tx receipt for %s" self.logger.info(msg, tx_result.source_chain.name, tx_result.source_tx.tx_hash) - tx_receipt = wait_for_tx_finality( + tx_receipt = await wait_for_tx_finality( w3=context.source_w3, tx_hash=tx_result.source_tx.tx_hash, logger=self.logger, @@ -493,7 +498,7 @@ def confirm_source_tx(self, tx_result: BridgeTxResult) -> TxReceipt: return tx_receipt - def wait_for_target_event(self, tx_result: BridgeTxResult) -> HexBytes: + async def wait_for_target_event(self, tx_result: BridgeTxResult) -> HexBytes: bridge_event_fetchers = { BridgeType.SOCKET: self._fetch_socket_event_log, @@ -503,18 +508,18 @@ def wait_for_target_event(self, tx_result: BridgeTxResult) -> HexBytes: raise BridgeRouteError(f"Invalid bridge_type: {tx_result.bridge}") context = self._get_context(tx_result) - event_log = fetch_event(tx_result, context) + event_log = await fetch_event(tx_result, context) tx_hash = event_log["transactionHash"] self.logger.info(f"Target event tx_hash found: {tx_hash.to_0x_hex()}") return tx_hash - def confirm_target_tx(self, tx_result: BridgeTxResult) -> TxReceipt: + async def confirm_target_tx(self, tx_result: BridgeTxResult) -> TxReceipt: context = self._get_context(tx_result) msg = "⏳ Checking target chain [%s] tx receipt for %s" self.logger.info(msg, tx_result.target_chain.name, tx_result.target_tx.tx_hash) - tx_receipt = wait_for_tx_finality( + tx_receipt = await wait_for_tx_finality( w3=context.target_w3, tx_hash=tx_result.target_tx.tx_hash, logger=self.logger, @@ -522,7 +527,7 @@ def confirm_target_tx(self, tx_result: BridgeTxResult) -> TxReceipt: return tx_receipt - def _fetch_lz_event_log(self, tx_result: BridgeTxResult, context: BridgeContext) -> LogReceipt: + async def _fetch_lz_event_log(self, tx_result: BridgeTxResult, context: BridgeContext) -> LogReceipt: try: source_event = context.source_event.process_log(tx_result.source_tx.tx_receipt.logs[-1]) @@ -543,9 +548,9 @@ def _fetch_lz_event_log(self, tx_result: BridgeTxResult, context: BridgeContext) f"🔍 Listening for OFTReceived on [{tx_result.target_chain.name}] at {context.target_event.address}" ) - return wait_for_event(context.target_w3, filter_params, logger=self.logger) + return await wait_for_event(context.target_w3, filter_params, logger=self.logger) - def _fetch_socket_event_log(self, tx_result: BridgeTxResult, context: BridgeContext) -> LogReceipt: + async def _fetch_socket_event_log(self, tx_result: BridgeTxResult, context: BridgeContext) -> LogReceipt: try: source_event = context.source_event.process_log(tx_result.source_tx.tx_receipt.logs[-2]) @@ -567,13 +572,13 @@ def matching_message_id(log: AttributeDict) -> bool: f"🔍 Listening for ExecutionSuccess on [{tx_result.target_chain.name}] at {context.target_event.address}" ) - return wait_for_event(context.target_w3, filter_params, condition=matching_message_id, logger=self.logger) + return await wait_for_event(context.target_w3, filter_params, condition=matching_message_id, logger=self.logger) def _prepare_new_style_deposit(self, token_data: NonMintableTokenData, amount: int) -> tuple[ContractFunction, int]: vault_contract = _load_vault_contract(w3=self.remote_w3, token_data=token_data) connector = token_data.connectors[ChainID.DERIVE][TARGET_SPEED] - fees = _get_min_fees(bridge_contract=vault_contract, connector=connector, token_data=token_data) + fees_func =_get_min_fees(bridge_contract=vault_contract, connector=connector, token_data=token_data) func = vault_contract.functions.bridge( receiver_=self.wallet, amount_=amount, @@ -583,13 +588,13 @@ def _prepare_new_style_deposit(self, token_data: NonMintableTokenData, amount: i options_=b"", ) - return func, fees + return func, fees_func def _prepare_old_style_deposit(self, token_data: NonMintableTokenData, amount: int) -> tuple[ContractFunction, int]: vault_contract = _load_vault_contract(w3=self.remote_w3, token_data=token_data) connector = token_data.connectors[ChainID.DERIVE][TARGET_SPEED] - fees = _get_min_fees(bridge_contract=vault_contract, connector=connector, token_data=token_data) + fees_func = _get_min_fees(bridge_contract=vault_contract, connector=connector, token_data=token_data) func = self.deposit_helper.functions.depositToLyra( token=token_data.NonMintableToken, socketVault=token_data.Vault, @@ -599,7 +604,7 @@ def _prepare_old_style_deposit(self, token_data: NonMintableTokenData, amount: i connector=connector, ) - return func, fees + return func, fees_func def _check_bridge_funds(self, token_data, connector: Address, amount: int) -> None: diff --git a/derive_client/_bridge/transaction.py b/derive_client/_bridge/transaction.py index 5c6de860..1880e100 100644 --- a/derive_client/_bridge/transaction.py +++ b/derive_client/_bridge/transaction.py @@ -9,15 +9,15 @@ from derive_client.utils import build_standard_transaction, send_tx, sign_tx, wait_for_tx_finality -def ensure_token_balance(token_contract: Contract, owner: Address, amount: int): - balance = token_contract.functions.balanceOf(owner).call() +async def ensure_token_balance(token_contract: Contract, owner: Address, amount: int): + balance = await token_contract.functions.balanceOf(owner).call() if amount > balance: raise InsufficientTokenBalance( f"Not enough tokens to withdraw: {amount} < {balance} ({(balance / amount * 100):.2f}%)" ) -def ensure_token_allowance( +async def ensure_token_allowance( w3: Web3, token_contract: Contract, owner: Address, @@ -26,10 +26,10 @@ def ensure_token_allowance( private_key: str, logger: Logger, ): - allowance = token_contract.functions.allowance(owner, spender).call() + allowance = await token_contract.functions.allowance(owner, spender).call() if amount > allowance: logger.info(f"Increasing allowance from {allowance} to {amount}") - _increase_token_allowance( + await _increase_token_allowance( w3=w3, from_account=Account.from_key(private_key), erc20_contract=token_contract, @@ -40,7 +40,7 @@ def ensure_token_allowance( ) -def _increase_token_allowance( +async def _increase_token_allowance( w3: Web3, from_account: Account, erc20_contract: Contract, @@ -50,9 +50,9 @@ def _increase_token_allowance( logger: Logger, ) -> None: func = erc20_contract.functions.approve(spender, amount) - tx = build_standard_transaction(func=func, account=from_account, w3=w3) + tx = await build_standard_transaction(func=func, account=from_account, w3=w3) signed_tx = sign_tx(w3=w3, tx=tx, private_key=private_key) - tx_hash = send_tx(w3=w3, signed_tx=signed_tx) - tx_receipt = wait_for_tx_finality(w3=w3, tx_hash=tx_hash, logger=logger) + tx_hash = await send_tx(w3=w3, signed_tx=signed_tx) + tx_receipt = await wait_for_tx_finality(w3=w3, tx_hash=tx_hash, logger=logger) if tx_receipt.status != TxStatus.SUCCESS: raise RuntimeError("approve() failed") diff --git a/derive_client/clients/base_client.py b/derive_client/clients/base_client.py index 821506aa..f7621bb3 100644 --- a/derive_client/clients/base_client.py +++ b/derive_client/clients/base_client.py @@ -2,6 +2,7 @@ Base Client for the derive dex. """ +import asyncio import json import random from decimal import Decimal @@ -163,10 +164,17 @@ def deposit_to_derive_result( amount = int(amount * 10 ** TOKEN_DECIMALS[UnderlyingCurrency[currency.name.upper()]]) client = BridgeClient(self.env, chain_id, account=self.signer, wallet=self.wallet, logger=self.logger) - prepared_tx = client.prepare_deposit(amount=amount, currency=currency) - tx_result = client.submit_bridge_tx(prepared_tx) + # prepared_tx = client.prepare_deposit(amount=amount, currency=currency) + # tx_result = client.submit_bridge_tx(prepared_tx) - return client.poll_bridge_progress(tx_result=tx_result) + # return client.poll_bridge_progress(tx_result=tx_result) + + async def _run(): + prepared_tx = await client.prepare_deposit(amount=amount, currency=currency) + tx_result = await client.submit_bridge_tx(prepared_tx) + return await client.poll_bridge_progress(tx_result=tx_result) + + return asyncio.run(_run()) def deposit_to_derive( self, @@ -215,10 +223,17 @@ def withdraw_from_derive_result( amount = int(amount * 10 ** TOKEN_DECIMALS[UnderlyingCurrency[currency.name.upper()]]) client = BridgeClient(self.env, chain_id, account=self.signer, wallet=self.wallet, logger=self.logger) - prepared_tx = client.prepare_withdrawal(amount=amount, currency=currency) - tx_result = client.submit_bridge_tx(prepared_tx=prepared_tx) + # prepared_tx = client.prepare_withdrawal(amount=amount, currency=currency) + # tx_result = client.submit_bridge_tx(prepared_tx=prepared_tx) - return client.poll_bridge_progress(tx_result=tx_result) + # return client.poll_bridge_progress(tx_result=tx_result) + + async def _run(): + prepared_tx = await client.prepare_withdrawal(amount=amount, currency=currency) + tx_result = await client.submit_bridge_tx(prepared_tx) + return await client.poll_bridge_progress(tx_result=tx_result) + + return asyncio.run(_run()) def withdraw_from_derive( self, diff --git a/derive_client/constants.py b/derive_client/constants.py index 02c3f564..041589c3 100644 --- a/derive_client/constants.py +++ b/derive_client/constants.py @@ -82,7 +82,7 @@ class EnvConfig(BaseModel, frozen=True): Environment.PROD: EnvConfig( base_url="https://api.lyra.finance", ws_address="wss://api.lyra.finance/ws", - rpc_endpoint="https://rpc.lyra.finance", + rpc_endpoint="https://957.rpc.thirdweb.com/", block_explorer="https://explorer.lyra.finance", ACTION_TYPEHASH="0x4d7a9f27c403ff9c0f19bce61d76d82f9aa29f8d6d4b0c5474607d9770d1af17", DOMAIN_SEPARATOR="0xd96e5f90797da7ec8dc4e276260c7f3f87fedf68775fbe1ef116e996fc60441b", diff --git a/derive_client/data_types/models.py b/derive_client/data_types/models.py index 921fa7ac..e86d868f 100644 --- a/derive_client/data_types/models.py +++ b/derive_client/data_types/models.py @@ -11,8 +11,9 @@ from pydantic import BaseModel, ConfigDict, Field, GetCoreSchemaHandler, GetJsonSchemaHandler, HttpUrl from pydantic.dataclasses import dataclass from pydantic_core import core_schema -from web3 import Web3 -from web3.contract import Contract +from web3 import Web3, AsyncWeb3 +from web3.contract import Contract, AsyncContract +from web3.contract.async_contract import AsyncContractEvent from web3.contract.contract import ContractEvent from web3.datastructures import AttributeDict @@ -206,19 +207,25 @@ class ManagerAddress(BaseModel): @dataclass(config=ConfigDict(arbitrary_types_allowed=True)) class BridgeContext: - source_w3: Web3 - target_w3: Web3 - source_token: Contract - source_event: ContractEvent - target_event: ContractEvent + source_w3: AsyncWeb3 + target_w3: AsyncWeb3 + source_token: AsyncContract + source_event: AsyncContractEvent + target_event: AsyncContractEvent + source_chain: ChainID + target_chain: ChainID - @property - def source_chain(self) -> ChainID: - return ChainID(self.source_w3.eth.chain_id) + # async def init_chains(self): + # self._source_chain = ChainID(await self.source_w3.eth.chain_id) + # self._target_chain = ChainID(await self.target_w3.eth.chain_id) - @property - def target_chain(self) -> ChainID: - return ChainID(self.target_w3.eth.chain_id) + # @property + # def source_chain(self) -> ChainID: + # return self._source_chain + + # @property + # def target_chain(self) -> ChainID: + # return self._target_chain @dataclass diff --git a/derive_client/utils/retry.py b/derive_client/utils/retry.py index 03a6bc34..837bbb68 100644 --- a/derive_client/utils/retry.py +++ b/derive_client/utils/retry.py @@ -1,3 +1,4 @@ +import asyncio import functools import time from http import HTTPStatus @@ -35,15 +36,15 @@ def exp_backoff_retry( return lambda f: exp_backoff_retry(f, attempts=attempts, initial_delay=initial_delay, exceptions=exceptions) @functools.wraps(func) - def wrapper(*args, **kwargs): + async def wrapper(*args, **kwargs): delay = initial_delay for attempt in range(attempts): try: - return func(*args, **kwargs) + return await func(*args, **kwargs) except exceptions as e: if attempt == attempts - 1: raise e - time.sleep(delay) + await asyncio.sleep(delay) delay *= 2 return wrapper @@ -89,7 +90,7 @@ def log_response(r, *args, **kwargs): return session -def wait_until( +async def wait_until( func: Callable[P, T], condition: Callable[[T], bool], timeout: float = 60.0, @@ -115,7 +116,7 @@ def wait_until( if time.time() - start_time > timeout: msg = f"Timed out after {timeout}s waiting for condition on {func.__name__} {timeout_message}" raise TimeoutError(msg) - time.sleep(poll_interval) + await asyncio.sleep(poll_interval) def is_retryable(e: RequestException) -> bool: diff --git a/derive_client/utils/w3.py b/derive_client/utils/w3.py index caaaa41d..d8fd75ff 100644 --- a/derive_client/utils/w3.py +++ b/derive_client/utils/w3.py @@ -1,7 +1,7 @@ +import asyncio import functools import heapq import json -import threading import time from logging import Logger from pathlib import Path @@ -11,7 +11,7 @@ from eth_account import Account from eth_account.datastructures import SignedTransaction from requests import RequestException -from web3 import Web3 +from web3 import Web3, AsyncWeb3, AsyncHTTPProvider from web3.contract import Contract from web3.contract.contract import ContractEvent from web3.datastructures import AttributeDict @@ -62,20 +62,20 @@ def make_rotating_provider_middleware( heap: list[EndpointState] = [EndpointState(p) for p in endpoints] heapq.heapify(heap) - lock = threading.Lock() + lock = asyncio.Lock() - def middleware_factory(make_request: Callable[[str, Any], Any], w3: Web3) -> Callable[[str, Any], Any]: - def rotating_backoff(method: str, params: Any) -> Any: + async def middleware_factory(make_request: Callable[[str, Any], Any], w3: Web3) -> Callable[[str, Any], Any]: + async def rotating_backoff(method: str, params: Any) -> Any: now = time.monotonic() while True: # 1) grab the earlies-available endpoint - with lock: + async with lock: state = heapq.heappop(heap) # 2) if it's not yet ready, push back and error out if state.next_available > now: - with lock: + async with lock: heapq.heappush(heap, state) msg = "All RPC endpoints are cooling down. Try again in %.2f seconds." logger.warning(msg, state.next_available - now) @@ -83,14 +83,14 @@ def rotating_backoff(method: str, params: Any) -> Any: try: # 3) attempt the request - resp = state.provider.make_request(method, params) + resp = await state.provider.make_request(method, params) # Json‑RPC error branch if isinstance(resp, dict) and (error := resp.get("error")): state.backoff = state.backoff * 2 if state.backoff else initial_backoff state.backoff = min(state.backoff, max_backoff) state.next_available = now + state.backoff - with lock: + async with lock: heapq.heappush(heap, state) err_msg = error.get("message", "") msg = "RPC error on %s: %s → backing off %.2fs" @@ -100,7 +100,7 @@ def rotating_backoff(method: str, params: Any) -> Any: # 4) on success, reset its backoff and re-schedule immediately state.backoff = 0.0 state.next_available = now - with lock: + async with lock: heapq.heappush(heap, state) return resp @@ -117,7 +117,7 @@ def rotating_backoff(method: str, params: Any) -> Any: # cap backoff and schedule state.backoff = min(backoff, max_backoff) state.next_available = now + state.backoff - with lock: + async with lock: heapq.heappush(heap, state) msg = "Backing off %s for %.2fs" logger.info(msg, state.provider.endpoint_uri, backoff) @@ -127,7 +127,7 @@ def rotating_backoff(method: str, params: Any) -> Any: logger.exception(msg, method, params, state.provider.endpoint_uri, max_backoff, exc_info=e) state.backoff = max_backoff state.next_available = now + state.backoff - with lock: + async with lock: heapq.heappush(heap, state) continue @@ -148,12 +148,12 @@ def get_w3_connection( logger: Logger | None = None, ) -> Web3: rpc_endpoints = rpc_endpoints or load_rpc_endpoints(DEFAULT_RPC_ENDPOINTS) - providers = [HTTPProvider(url) for url in rpc_endpoints[chain_id]] + providers = [AsyncHTTPProvider(str(url)) for url in rpc_endpoints[chain_id]] logger = logger or get_logger() # NOTE: Initial provider is a no-op once middleware is in place - w3 = Web3() + w3 = AsyncWeb3(providers[0]) rotator = make_rotating_provider_middleware( providers, initial_backoff=1.0, @@ -174,8 +174,8 @@ def get_erc20_contract(w3: Web3, token_address: str) -> Contract: return get_contract(w3=w3, address=token_address, abi=abi) -def simulate_tx(w3: Web3, tx: dict, account: Account) -> dict: - balance = w3.eth.get_balance(account.address) +async def simulate_tx(w3: Web3, tx: dict, account: Account) -> dict: + balance = await w3.eth.get_balance(account.address) max_fee_per_gas = tx["maxFeePerGas"] gas_limit = tx["gas"] value = tx.get("value", 0) @@ -186,12 +186,12 @@ def simulate_tx(w3: Web3, tx: dict, account: Account) -> dict: ratio = balance / total_cost * 100 raise InsufficientNativeBalance(f"available: {balance} < required: {total_cost} ({ratio:.2f}%)") - w3.eth.call(tx) + await w3.eth.call(tx) return tx -@exp_backoff_retry -def build_standard_transaction( +# @exp_backoff_retry +async def build_standard_transaction( func, account: Account, w3: Web3, @@ -201,31 +201,31 @@ def build_standard_transaction( ) -> dict: """Standardized transaction building with EIP-1559 and gas estimation""" - nonce = w3.eth.get_transaction_count(account.address) - fee_estimations = estimate_fees(w3, blocks=gas_blocks, percentiles=[gas_percentile]) + nonce = await w3.eth.get_transaction_count(account.address) + fee_estimations = await estimate_fees(w3, blocks=gas_blocks, percentiles=[gas_percentile]) max_fee = fee_estimations[0]["maxFeePerGas"] priority_fee = fee_estimations[0]["maxPriorityFeePerGas"] - tx = func.build_transaction( + tx = await func.build_transaction( { "from": account.address, "nonce": nonce, "maxFeePerGas": max_fee, "maxPriorityFeePerGas": priority_fee, - "chainId": w3.eth.chain_id, + "chainId": await w3.eth.chain_id, "value": value, } ) - tx["gas"] = int(w3.eth.estimate_gas(tx) * GAS_LIMIT_BUFFER) - return simulate_tx(w3, tx, account) + tx["gas"] = int(await w3.eth.estimate_gas(tx) * GAS_LIMIT_BUFFER) + return await simulate_tx(w3, tx, account) -def wait_for_tx_finality( +async def wait_for_tx_finality( w3: Web3, tx_hash: str, logger: Logger, - finality_blocks: int = 120, + finality_blocks: int = 10, timeout: float = 300.0, poll_interval: float = 1.0, ) -> AttributeDict: @@ -256,7 +256,7 @@ def wait_for_tx_finality( while True: try: - receipt = AttributeDict(w3.eth.get_transaction_receipt(tx_hash)) + receipt = AttributeDict(await w3.eth.get_transaction_receipt(tx_hash)) # receipt can disappear temporarily during reorgs, or if RPC provider is not synced except Exception as exc: receipt = None @@ -264,7 +264,7 @@ def wait_for_tx_finality( # blockNumber can change as tx gets reorged into different blocks try: - if receipt is not None and (block_number := w3.eth.block_number) >= receipt.blockNumber + finality_blocks: + if receipt is not None and (block_number := await w3.eth.block_number) >= receipt.blockNumber + finality_blocks: return receipt except Exception as exc: msg = "Failed to fetch block_number trying to assess finality of tx_hash=%s" @@ -310,21 +310,21 @@ def wait_for_tx_finality( ) logger.debug("Waiting for finality: tx=%s sleeping=%.1fs", tx_hash, poll_interval) - time.sleep(poll_interval) + await asyncio.sleep(poll_interval) def sign_tx(w3: Web3, tx: dict, private_key: str) -> SignedTransaction: - signed_tx = w3.eth.account.sign_transaction(tx, private_key=private_key) + signed_tx =w3.eth.account.sign_transaction(tx, private_key=private_key) return signed_tx -def send_tx(w3: Web3, signed_tx: SignedTransaction) -> str: - tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction) +async def send_tx(w3: Web3, signed_tx: SignedTransaction) -> str: + tx_hash = await w3.eth.send_raw_transaction(signed_tx.raw_transaction) return tx_hash.to_0x_hex() -def estimate_fees(w3, percentiles: list[int], blocks=20, default_tip=10_000): - fee_history = w3.eth.fee_history(blocks, "pending", percentiles) +async def estimate_fees(w3, percentiles: list[int], blocks=20, default_tip=10_000): + fee_history = await w3.eth.fee_history(blocks, "pending", percentiles) base_fees = fee_history["baseFeePerGas"] rewards = fee_history["reward"] @@ -350,7 +350,7 @@ def estimate_fees(w3, percentiles: list[int], blocks=20, default_tip=10_000): return fee_estimations -def iter_events( +async def iter_events( w3: Web3, filter_params: dict, *, @@ -364,7 +364,7 @@ def iter_events( original_filter_params = filter_params.copy() # return original in TimeoutError if (cursor := filter_params["fromBlock"]) == "latest": - cursor = w3.eth.block_number + cursor = await w3.eth.block_number start_block = cursor filter_params["toBlock"] = filter_params.get("toBlock", "latest") @@ -376,25 +376,26 @@ def iter_events( msg = f"Timed out waiting for events after scanning blocks {start_block}-{cursor}" logger.warning(msg) raise TimeoutError(f"{msg}: filter_params: {original_filter_params}") - upper = fixed_ceiling or w3.eth.block_number + upper = fixed_ceiling or await w3.eth.block_number if cursor <= upper: end = min(upper, cursor + max_block_range - 1) filter_params["fromBlock"] = hex(cursor) filter_params["toBlock"] = hex(end) # For example, when rotating providers are out of sync retry_get_logs = exp_backoff_retry(w3.eth.get_logs, attempts=EVENT_LOG_RETRIES) - logs = retry_get_logs(filter_params=filter_params) + logs = await retry_get_logs(filter_params=filter_params) logger.debug(f"Scanned {cursor} - {end}: {len(logs)} logs") - yield from filter(condition, logs) + for log in filter(condition, logs): + yield log cursor = end + 1 # bounds are inclusive if fixed_ceiling and cursor > fixed_ceiling: raise StopIteration - time.sleep(poll_interval) + await asyncio.sleep(poll_interval) -def wait_for_event( +async def wait_for_event( w3: Web3, filter_params: dict, *, @@ -406,7 +407,7 @@ def wait_for_event( ) -> AttributeDict: """Return the first log from iter_events, or raise TimeoutError after `timeout` seconds.""" - return next(iter_events(**locals())) + return await anext(iter_events(**locals())) def make_filter_params( From 0909b0ebc8a2980d9f652c2652a3ba39e724fc27 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Thu, 14 Aug 2025 13:01:40 +0200 Subject: [PATCH 042/101] refactor: move w3 functionality used solely in bridge to _bridge module --- derive_client/_bridge/client.py | 4 +- derive_client/_bridge/transaction.py | 2 +- derive_client/_bridge/w3.py | 422 +++++++++++++++++++++++++++ derive_client/utils/__init__.py | 28 +- derive_client/utils/w3.py | 325 ++------------------- 5 files changed, 444 insertions(+), 337 deletions(-) create mode 100644 derive_client/_bridge/w3.py diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index e6c54995..b7482d80 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -61,10 +61,10 @@ DrvWithdrawAmountBelowFee, PartialBridgeResult, ) -from derive_client.utils import ( +from derive_client.utils import get_prod_derive_addresses +from .utils.w3 import ( build_standard_transaction, get_contract, - get_prod_derive_addresses, get_w3_connection, make_filter_params, send_tx, diff --git a/derive_client/_bridge/transaction.py b/derive_client/_bridge/transaction.py index 1880e100..5591d8a1 100644 --- a/derive_client/_bridge/transaction.py +++ b/derive_client/_bridge/transaction.py @@ -6,7 +6,7 @@ from derive_client.data_types import Address, TxStatus from derive_client.exceptions import InsufficientTokenBalance -from derive_client.utils import build_standard_transaction, send_tx, sign_tx, wait_for_tx_finality +from .w3 import build_standard_transaction, send_tx, sign_tx, wait_for_tx_finality async def ensure_token_balance(token_contract: Contract, owner: Address, amount: int): diff --git a/derive_client/_bridge/w3.py b/derive_client/_bridge/w3.py new file mode 100644 index 00000000..760e8d1b --- /dev/null +++ b/derive_client/_bridge/w3.py @@ -0,0 +1,422 @@ +import asyncio +import functools +import heapq +import json +import time +from logging import Logger +from pathlib import Path +from typing import Any, Callable, Generator, Literal + +import yaml +from eth_account import Account +from eth_account.datastructures import SignedTransaction +from requests import RequestException +from web3 import Web3, AsyncWeb3, AsyncHTTPProvider +from web3.contract import Contract +from web3.contract.contract import ContractEvent +from web3.datastructures import AttributeDict +from web3.providers.rpc import HTTPProvider + +from derive_client.constants import ABI_DATA_DIR, DEFAULT_RPC_ENDPOINTS, GAS_FEE_BUFFER, GAS_LIMIT_BUFFER +from derive_client.data_types import ChainID, RPCEndpoints +from derive_client.exceptions import ( + FinalityTimeout, + InsufficientNativeBalance, + NoAvailableRPC, + TransactionDropped, + TxPendingTimeout, +) +from derive_client.utils.logger import get_logger +from derive_client.utils.retry import exp_backoff_retry +from derive_client.utils.w3 import load_rpc_endpoints, EndpointState + + +EVENT_LOG_RETRIES = 10 + + +def make_rotating_provider_middleware( + endpoints: list[HTTPProvider], + *, + initial_backoff: float = 1.0, + max_backoff: float = 600.0, + logger: Logger, +) -> Callable[[Callable[[str, Any], Any], Web3], Callable[[str, Any], Any]]: + """ + v6.11-style middleware: + - round-robin via a min-heap of `next_available` times + - on 429: exponential back-off for that endpoint, capped + """ + + heap: list[EndpointState] = [EndpointState(p) for p in endpoints] + heapq.heapify(heap) + lock = asyncio.Lock() + + async def middleware_factory(make_request: Callable[[str, Any], Any], w3: Web3) -> Callable[[str, Any], Any]: + async def rotating_backoff(method: str, params: Any) -> Any: + now = time.monotonic() + + while True: + # 1) grab the earlies-available endpoint + async with lock: + state = heapq.heappop(heap) + + # 2) if it's not yet ready, push back and error out + if state.next_available > now: + async with lock: + heapq.heappush(heap, state) + msg = "All RPC endpoints are cooling down. Try again in %.2f seconds." + logger.warning(msg, state.next_available - now) + raise NoAvailableRPC(msg) + + try: + # 3) attempt the request + resp = await state.provider.make_request(method, params) + + # Json‑RPC error branch + if isinstance(resp, dict) and (error := resp.get("error")): + state.backoff = state.backoff * 2 if state.backoff else initial_backoff + state.backoff = min(state.backoff, max_backoff) + state.next_available = now + state.backoff + async with lock: + heapq.heappush(heap, state) + err_msg = error.get("message", "") + msg = "RPC error on %s: %s → backing off %.2fs" + logger.info(msg, state.provider.endpoint_uri, err_msg, state.backoff, extra=resp) + continue + + # 4) on success, reset its backoff and re-schedule immediately + state.backoff = 0.0 + state.next_available = now + async with lock: + heapq.heappush(heap, state) + return resp + + except RequestException as e: + logger.debug("Endpoint %s failed: %s", state.provider.endpoint_uri, e) + + # We retry on all exceptions + hdr = (e.response and e.response.headers or {}).get("Retry-After") + try: + backoff = float(hdr) + except (ValueError, TypeError): + backoff = state.backoff * 2 if state.backoff > 0 else initial_backoff + + # cap backoff and schedule + state.backoff = min(backoff, max_backoff) + state.next_available = now + state.backoff + async with lock: + heapq.heappush(heap, state) + msg = "Backing off %s for %.2fs" + logger.info(msg, state.provider.endpoint_uri, backoff) + continue + except Exception as e: + msg = "Unexpected error calling %s %s on %s; backing off %.2fs and continuing" + logger.exception(msg, method, params, state.provider.endpoint_uri, max_backoff, exc_info=e) + state.backoff = max_backoff + state.next_available = now + state.backoff + async with lock: + heapq.heappush(heap, state) + continue + + return rotating_backoff + + return middleware_factory + + +def get_w3_connection( + chain_id: ChainID, + *, + rpc_endpoints: RPCEndpoints | None = None, + logger: Logger | None = None, +) -> Web3: + rpc_endpoints = rpc_endpoints or load_rpc_endpoints(DEFAULT_RPC_ENDPOINTS) + providers = [AsyncHTTPProvider(str(url)) for url in rpc_endpoints[chain_id]] + + logger = logger or get_logger() + + # NOTE: Initial provider is a no-op once middleware is in place + w3 = AsyncWeb3(providers[0]) + rotator = make_rotating_provider_middleware( + providers, + initial_backoff=1.0, + max_backoff=600.0, + logger=logger, + ) + w3.middleware_onion.add(rotator) + return w3 + + +def get_contract(w3: Web3, address: str, abi: list) -> Contract: + return w3.eth.contract(address=Web3.to_checksum_address(address), abi=abi) + + +def get_erc20_contract(w3: Web3, token_address: str) -> Contract: + erc20_abi_path = ABI_DATA_DIR / "erc20.json" + abi = json.loads(erc20_abi_path.read_text()) + return get_contract(w3=w3, address=token_address, abi=abi) + + +async def simulate_tx(w3: Web3, tx: dict, account: Account) -> dict: + balance = await w3.eth.get_balance(account.address) + max_fee_per_gas = tx["maxFeePerGas"] + gas_limit = tx["gas"] + value = tx.get("value", 0) + + max_gas_cost = gas_limit * max_fee_per_gas + total_cost = max_gas_cost + value + if not balance >= total_cost: + ratio = balance / total_cost * 100 + raise InsufficientNativeBalance(f"available: {balance} < required: {total_cost} ({ratio:.2f}%)") + + await w3.eth.call(tx) + return tx + + +# @exp_backoff_retry +async def build_standard_transaction( + func, + account: Account, + w3: Web3, + value: int = 0, + gas_blocks: int = 100, + gas_percentile: int = 99, +) -> dict: + """Standardized transaction building with EIP-1559 and gas estimation""" + + nonce = await w3.eth.get_transaction_count(account.address) + fee_estimations = await estimate_fees(w3, blocks=gas_blocks, percentiles=[gas_percentile]) + max_fee = fee_estimations[0]["maxFeePerGas"] + priority_fee = fee_estimations[0]["maxPriorityFeePerGas"] + + tx = await func.build_transaction( + { + "from": account.address, + "nonce": nonce, + "maxFeePerGas": max_fee, + "maxPriorityFeePerGas": priority_fee, + "chainId": await w3.eth.chain_id, + "value": value, + } + ) + + tx["gas"] = int(await w3.eth.estimate_gas(tx) * GAS_LIMIT_BUFFER) + return await simulate_tx(w3, tx, account) + + +async def wait_for_tx_finality( + w3: Web3, + tx_hash: str, + logger: Logger, + finality_blocks: int = 10, + timeout: float = 300.0, + poll_interval: float = 1.0, +) -> AttributeDict: + """ + Wait until tx is mined and has `finality_blocks` confirmations. + On timeout this raises one of: + - FinalityTimeout: receipt exists but not enough confirmations + - TxPendingTimeout: no receipt, but tx present and pending in mempool + - TransactionDropped: no receipt and tx not known to node (likely dropped) + + Notes on reorgs and provider inconsistency: + - A chain reorg can cause a previously-seen receipt to disappear (tx becomes "unmined"). + In that case the tx will often reappear as pending in the mempool (TxPendingTimeout), + but it can also be dropped entirely (TransactionDropped) or re-mined later. + - With rotating RPC providers you may observe receipts, tx entries, and block numbers + from different nodes that disagree. This function classifies a timeout based on a + single get_transaction probe and is intentionally conservative; callers should + interpret exceptions as: + * FinalityTimeout: node reports mined or we observed a receipt but not enough confirms: + wait longer; invoke this function again. + * TxPendingTimeout: node knows the tx and reports it pending: + either wait/poll longer or resubmit (reuse the nonce to prevent duplication). + * TransactionDropped: node has no record (likely dropped or node out-of-sync): + either wait/poll longer or resubmit (reuse the nonce to prevent duplication). + """ + + start_time = time.monotonic() + + while True: + try: + receipt = AttributeDict(await w3.eth.get_transaction_receipt(tx_hash)) + # receipt can disappear temporarily during reorgs, or if RPC provider is not synced + except Exception as exc: + receipt = None + logger.debug("No tx receipt for tx_hash=%s", tx_hash, extra={"exc": exc}) + + # blockNumber can change as tx gets reorged into different blocks + try: + if receipt is not None and (block_number := await w3.eth.block_number) >= receipt.blockNumber + finality_blocks: + return receipt + except Exception as exc: + msg = "Failed to fetch block_number trying to assess finality of tx_hash=%s" + logger.debug(msg, tx_hash, extra={"exc": exc}) + + if time.monotonic() - start_time > timeout: + # 1) We have a receipt but did not reach required confirmations + if receipt is not None: + raise FinalityTimeout( + f"Timed out waiting for finality: tx={tx_hash!r}, timeout_s={timeout}r ", + f"required confirmations={finality_blocks}." + f"\nreceipt_block={receipt.blockNumber!r}, current_block={block_number!r}.", + "\nAction: wait longer / poll for finality again.", + ) + # 2) No receipt: check if tx is known to node (mempool) or dropped + try: + tx = AttributeDict(w3.eth.get_transaction(tx_hash)) + except Exception as exc: + tx = None + logger.debug("get_transaction probe failed for tx_hash=%s", tx_hash, extra={"exc": exc}) + + # still pending in mempool + if tx is not None and tx.blockNumber is None: + raise TxPendingTimeout( + f"No receipt within timeout: tx={tx_hash!r}, timeout_s={timeout}.", + "\nNode reports transaction present and pending in mempool.", + "\nAction: either wait/poll longer or resubmit (reuse the nonce to prevent duplication).", + ) + # node reports tx mined, but no receipt + elif tx is not None: + raise FinalityTimeout( + f"Timed out waiting for finality: tx={tx_hash!r}, timeout_s={timeout}, " + f"required confirmations={finality_blocks}." + f"\nNode reports tx mined at block {tx.blockNumber!r} but receipt not observed by this verifier." + "\nAction: wait longer / poll for finality again.", + ) + # tx dropped or node no longer knows about it + else: + raise TransactionDropped( + f"Transaction not found after timeout: tx={tx_hash!r}, timeout_s={timeout}.", + "\nNode does not report a receipt or pending transaction (likely dropped).", + "\nAction: either wait/poll longer or resubmit (reuse the nonce to prevent duplication).", + ) + + logger.debug("Waiting for finality: tx=%s sleeping=%.1fs", tx_hash, poll_interval) + await asyncio.sleep(poll_interval) + + +def sign_tx(w3: Web3, tx: dict, private_key: str) -> SignedTransaction: + signed_tx =w3.eth.account.sign_transaction(tx, private_key=private_key) + return signed_tx + + +async def send_tx(w3: Web3, signed_tx: SignedTransaction) -> str: + tx_hash = await w3.eth.send_raw_transaction(signed_tx.raw_transaction) + return tx_hash.to_0x_hex() + + +async def estimate_fees(w3, percentiles: list[int], blocks=20, default_tip=10_000): + fee_history = await w3.eth.fee_history(blocks, "pending", percentiles) + base_fees = fee_history["baseFeePerGas"] + rewards = fee_history["reward"] + + # Calculate average priority fees for each percentile + avg_priority_fees = [] + for i in range(len(percentiles)): + nonzero_rewards = [r[i] for r in rewards if len(r) > i and r[i] > 0] + if nonzero_rewards: + estimated_tip = sum(nonzero_rewards) // len(nonzero_rewards) + else: + estimated_tip = default_tip + avg_priority_fees.append(estimated_tip) + + # Use the latest base fee + latest_base_fee = base_fees[-1] + + # Calculate max fees + fee_estimations = [] + for priority_fee in avg_priority_fees: + max_fee = int((latest_base_fee + priority_fee) * GAS_FEE_BUFFER) + fee_estimations.append({"maxFeePerGas": max_fee, "maxPriorityFeePerGas": priority_fee}) + + return fee_estimations + + +async def iter_events( + w3: Web3, + filter_params: dict, + *, + condition: Callable[[AttributeDict], bool] = lambda _: True, + max_block_range: int = 10_000, + poll_interval: float = 5.0, + timeout: float | None = None, + logger: Logger, +) -> Generator[AttributeDict, None, None]: + """Stream matching logs over a fixed or live block window. Optionally raises TimeoutError.""" + + original_filter_params = filter_params.copy() # return original in TimeoutError + if (cursor := filter_params["fromBlock"]) == "latest": + cursor = await w3.eth.block_number + + start_block = cursor + filter_params["toBlock"] = filter_params.get("toBlock", "latest") + fixed_ceiling = None if filter_params["toBlock"] == "latest" else filter_params["toBlock"] + + deadline = None if timeout is None else time.monotonic() + timeout + while True: + if deadline and time.monotonic() > deadline: + msg = f"Timed out waiting for events after scanning blocks {start_block}-{cursor}" + logger.warning(msg) + raise TimeoutError(f"{msg}: filter_params: {original_filter_params}") + upper = fixed_ceiling or await w3.eth.block_number + if cursor <= upper: + end = min(upper, cursor + max_block_range - 1) + filter_params["fromBlock"] = hex(cursor) + filter_params["toBlock"] = hex(end) + # For example, when rotating providers are out of sync + retry_get_logs = exp_backoff_retry(w3.eth.get_logs, attempts=EVENT_LOG_RETRIES) + logs = await retry_get_logs(filter_params=filter_params) + logger.debug(f"Scanned {cursor} - {end}: {len(logs)} logs") + for log in filter(condition, logs): + yield log + cursor = end + 1 # bounds are inclusive + + if fixed_ceiling and cursor > fixed_ceiling: + raise StopIteration + + await asyncio.sleep(poll_interval) + + +async def wait_for_event( + w3: Web3, + filter_params: dict, + *, + condition: Callable[[AttributeDict], bool] = lambda _: True, + max_block_range: int = 10_000, + poll_interval: float = 5.0, + timeout: float = 300.0, + logger: Logger, +) -> AttributeDict: + """Return the first log from iter_events, or raise TimeoutError after `timeout` seconds.""" + + return await anext(iter_events(**locals())) + + +def make_filter_params( + event: ContractEvent, + from_block: int | Literal["latest"], + to_block: int | Literal["latest"] = "latest", + argument_filters: dict | None = None, +) -> dict: + """ + Function to create an eth_getLogs compatible filter_params for this event without using .create_filter. + event.create_filter uses eth_newFilter (a "push"), which not all RPC endpoints support. + """ + + argument_filters = argument_filters or {} + filter_params = event._get_event_filter_params( + fromBlock=from_block, + toBlock=to_block, + argument_filters=argument_filters, + abi=event.abi, + ) + filter_params["topics"] = tuple(filter_params["topics"]) + address = filter_params["address"] + if isinstance(address, str): + filter_params["address"] = Web3.to_checksum_address(address) + elif isinstance(address, (list, tuple)) and len(address) == 1: + filter_params["address"] = Web3.to_checksum_address(address[0]) + else: + raise ValueError(f"Unexpected address filter: {address!r}") + + return filter_params diff --git a/derive_client/utils/__init__.py b/derive_client/utils/__init__.py index ad1540e2..3c32ac70 100644 --- a/derive_client/utils/__init__.py +++ b/derive_client/utils/__init__.py @@ -5,41 +5,17 @@ from .prod_addresses import get_prod_derive_addresses from .retry import exp_backoff_retry, get_retry_session, wait_until from .unwrap import unwrap_or_raise -from .w3 import ( - build_standard_transaction, - estimate_fees, - get_contract, - get_erc20_contract, - get_w3_connection, - iter_events, - load_rpc_endpoints, - make_filter_params, - make_rotating_provider_middleware, - send_tx, - sign_tx, - wait_for_event, - wait_for_tx_finality, -) +from .w3 import get_w3_connection, load_rpc_endpoints + __all__ = [ - "estimate_fees", "get_logger", "get_prod_derive_addresses", "exp_backoff_retry", "get_retry_session", - "make_filter_params", - "make_rotating_provider_middleware", "wait_until", "get_w3_connection", - "get_contract", - "get_erc20_contract", "load_rpc_endpoints", - "wait_for_tx_finality", - "sign_tx", - "send_tx", "download_prod_address_abis", - "build_standard_transaction", - "iter_events", - "wait_for_event", "unwrap_or_raise", ] diff --git a/derive_client/utils/w3.py b/derive_client/utils/w3.py index d8fd75ff..5d64ffb0 100644 --- a/derive_client/utils/w3.py +++ b/derive_client/utils/w3.py @@ -1,35 +1,20 @@ -import asyncio import functools import heapq -import json +import threading import time from logging import Logger from pathlib import Path -from typing import Any, Callable, Generator, Literal +from typing import Any, Callable import yaml -from eth_account import Account -from eth_account.datastructures import SignedTransaction from requests import RequestException -from web3 import Web3, AsyncWeb3, AsyncHTTPProvider -from web3.contract import Contract -from web3.contract.contract import ContractEvent -from web3.datastructures import AttributeDict +from web3 import Web3 from web3.providers.rpc import HTTPProvider -from derive_client.constants import ABI_DATA_DIR, DEFAULT_RPC_ENDPOINTS, GAS_FEE_BUFFER, GAS_LIMIT_BUFFER +from derive_client.constants import DEFAULT_RPC_ENDPOINTS from derive_client.data_types import ChainID, RPCEndpoints -from derive_client.exceptions import ( - FinalityTimeout, - InsufficientNativeBalance, - NoAvailableRPC, - TransactionDropped, - TxPendingTimeout, -) +from derive_client.exceptions import NoAvailableRPC from derive_client.utils.logger import get_logger -from derive_client.utils.retry import exp_backoff_retry - -EVENT_LOG_RETRIES = 10 class EndpointState: @@ -62,20 +47,20 @@ def make_rotating_provider_middleware( heap: list[EndpointState] = [EndpointState(p) for p in endpoints] heapq.heapify(heap) - lock = asyncio.Lock() + lock = threading.Lock() - async def middleware_factory(make_request: Callable[[str, Any], Any], w3: Web3) -> Callable[[str, Any], Any]: - async def rotating_backoff(method: str, params: Any) -> Any: + def middleware_factory(make_request: Callable[[str, Any], Any], w3: Web3) -> Callable[[str, Any], Any]: + def rotating_backoff(method: str, params: Any) -> Any: now = time.monotonic() while True: # 1) grab the earlies-available endpoint - async with lock: + with lock: state = heapq.heappop(heap) # 2) if it's not yet ready, push back and error out if state.next_available > now: - async with lock: + with lock: heapq.heappush(heap, state) msg = "All RPC endpoints are cooling down. Try again in %.2f seconds." logger.warning(msg, state.next_available - now) @@ -83,14 +68,14 @@ async def rotating_backoff(method: str, params: Any) -> Any: try: # 3) attempt the request - resp = await state.provider.make_request(method, params) + resp = state.provider.make_request(method, params) # Json‑RPC error branch if isinstance(resp, dict) and (error := resp.get("error")): state.backoff = state.backoff * 2 if state.backoff else initial_backoff state.backoff = min(state.backoff, max_backoff) state.next_available = now + state.backoff - async with lock: + with lock: heapq.heappush(heap, state) err_msg = error.get("message", "") msg = "RPC error on %s: %s → backing off %.2fs" @@ -100,7 +85,7 @@ async def rotating_backoff(method: str, params: Any) -> Any: # 4) on success, reset its backoff and re-schedule immediately state.backoff = 0.0 state.next_available = now - async with lock: + with lock: heapq.heappush(heap, state) return resp @@ -117,7 +102,7 @@ async def rotating_backoff(method: str, params: Any) -> Any: # cap backoff and schedule state.backoff = min(backoff, max_backoff) state.next_available = now + state.backoff - async with lock: + with lock: heapq.heappush(heap, state) msg = "Backing off %s for %.2fs" logger.info(msg, state.provider.endpoint_uri, backoff) @@ -127,7 +112,7 @@ async def rotating_backoff(method: str, params: Any) -> Any: logger.exception(msg, method, params, state.provider.endpoint_uri, max_backoff, exc_info=e) state.backoff = max_backoff state.next_available = now + state.backoff - async with lock: + with lock: heapq.heappush(heap, state) continue @@ -148,12 +133,12 @@ def get_w3_connection( logger: Logger | None = None, ) -> Web3: rpc_endpoints = rpc_endpoints or load_rpc_endpoints(DEFAULT_RPC_ENDPOINTS) - providers = [AsyncHTTPProvider(str(url)) for url in rpc_endpoints[chain_id]] + providers = [HTTPProvider(url) for url in rpc_endpoints[chain_id]] logger = logger or get_logger() # NOTE: Initial provider is a no-op once middleware is in place - w3 = AsyncWeb3(providers[0]) + w3 = Web3() rotator = make_rotating_provider_middleware( providers, initial_backoff=1.0, @@ -162,279 +147,3 @@ def get_w3_connection( ) w3.middleware_onion.add(rotator) return w3 - - -def get_contract(w3: Web3, address: str, abi: list) -> Contract: - return w3.eth.contract(address=Web3.to_checksum_address(address), abi=abi) - - -def get_erc20_contract(w3: Web3, token_address: str) -> Contract: - erc20_abi_path = ABI_DATA_DIR / "erc20.json" - abi = json.loads(erc20_abi_path.read_text()) - return get_contract(w3=w3, address=token_address, abi=abi) - - -async def simulate_tx(w3: Web3, tx: dict, account: Account) -> dict: - balance = await w3.eth.get_balance(account.address) - max_fee_per_gas = tx["maxFeePerGas"] - gas_limit = tx["gas"] - value = tx.get("value", 0) - - max_gas_cost = gas_limit * max_fee_per_gas - total_cost = max_gas_cost + value - if not balance >= total_cost: - ratio = balance / total_cost * 100 - raise InsufficientNativeBalance(f"available: {balance} < required: {total_cost} ({ratio:.2f}%)") - - await w3.eth.call(tx) - return tx - - -# @exp_backoff_retry -async def build_standard_transaction( - func, - account: Account, - w3: Web3, - value: int = 0, - gas_blocks: int = 100, - gas_percentile: int = 99, -) -> dict: - """Standardized transaction building with EIP-1559 and gas estimation""" - - nonce = await w3.eth.get_transaction_count(account.address) - fee_estimations = await estimate_fees(w3, blocks=gas_blocks, percentiles=[gas_percentile]) - max_fee = fee_estimations[0]["maxFeePerGas"] - priority_fee = fee_estimations[0]["maxPriorityFeePerGas"] - - tx = await func.build_transaction( - { - "from": account.address, - "nonce": nonce, - "maxFeePerGas": max_fee, - "maxPriorityFeePerGas": priority_fee, - "chainId": await w3.eth.chain_id, - "value": value, - } - ) - - tx["gas"] = int(await w3.eth.estimate_gas(tx) * GAS_LIMIT_BUFFER) - return await simulate_tx(w3, tx, account) - - -async def wait_for_tx_finality( - w3: Web3, - tx_hash: str, - logger: Logger, - finality_blocks: int = 10, - timeout: float = 300.0, - poll_interval: float = 1.0, -) -> AttributeDict: - """ - Wait until tx is mined and has `finality_blocks` confirmations. - On timeout this raises one of: - - FinalityTimeout: receipt exists but not enough confirmations - - TxPendingTimeout: no receipt, but tx present and pending in mempool - - TransactionDropped: no receipt and tx not known to node (likely dropped) - - Notes on reorgs and provider inconsistency: - - A chain reorg can cause a previously-seen receipt to disappear (tx becomes "unmined"). - In that case the tx will often reappear as pending in the mempool (TxPendingTimeout), - but it can also be dropped entirely (TransactionDropped) or re-mined later. - - With rotating RPC providers you may observe receipts, tx entries, and block numbers - from different nodes that disagree. This function classifies a timeout based on a - single get_transaction probe and is intentionally conservative; callers should - interpret exceptions as: - * FinalityTimeout: node reports mined or we observed a receipt but not enough confirms: - wait longer; invoke this function again. - * TxPendingTimeout: node knows the tx and reports it pending: - either wait/poll longer or resubmit (reuse the nonce to prevent duplication). - * TransactionDropped: node has no record (likely dropped or node out-of-sync): - either wait/poll longer or resubmit (reuse the nonce to prevent duplication). - """ - - start_time = time.monotonic() - - while True: - try: - receipt = AttributeDict(await w3.eth.get_transaction_receipt(tx_hash)) - # receipt can disappear temporarily during reorgs, or if RPC provider is not synced - except Exception as exc: - receipt = None - logger.debug("No tx receipt for tx_hash=%s", tx_hash, extra={"exc": exc}) - - # blockNumber can change as tx gets reorged into different blocks - try: - if receipt is not None and (block_number := await w3.eth.block_number) >= receipt.blockNumber + finality_blocks: - return receipt - except Exception as exc: - msg = "Failed to fetch block_number trying to assess finality of tx_hash=%s" - logger.debug(msg, tx_hash, extra={"exc": exc}) - - if time.monotonic() - start_time > timeout: - # 1) We have a receipt but did not reach required confirmations - if receipt is not None: - raise FinalityTimeout( - f"Timed out waiting for finality: tx={tx_hash!r}, timeout_s={timeout}r ", - f"required confirmations={finality_blocks}." - f"\nreceipt_block={receipt.blockNumber!r}, current_block={block_number!r}.", - "\nAction: wait longer / poll for finality again.", - ) - # 2) No receipt: check if tx is known to node (mempool) or dropped - try: - tx = AttributeDict(w3.eth.get_transaction(tx_hash)) - except Exception as exc: - tx = None - logger.debug("get_transaction probe failed for tx_hash=%s", tx_hash, extra={"exc": exc}) - - # still pending in mempool - if tx is not None and tx.blockNumber is None: - raise TxPendingTimeout( - f"No receipt within timeout: tx={tx_hash!r}, timeout_s={timeout}.", - "\nNode reports transaction present and pending in mempool.", - "\nAction: either wait/poll longer or resubmit (reuse the nonce to prevent duplication).", - ) - # node reports tx mined, but no receipt - elif tx is not None: - raise FinalityTimeout( - f"Timed out waiting for finality: tx={tx_hash!r}, timeout_s={timeout}, " - f"required confirmations={finality_blocks}." - f"\nNode reports tx mined at block {tx.blockNumber!r} but receipt not observed by this verifier." - "\nAction: wait longer / poll for finality again.", - ) - # tx dropped or node no longer knows about it - else: - raise TransactionDropped( - f"Transaction not found after timeout: tx={tx_hash!r}, timeout_s={timeout}.", - "\nNode does not report a receipt or pending transaction (likely dropped).", - "\nAction: either wait/poll longer or resubmit (reuse the nonce to prevent duplication).", - ) - - logger.debug("Waiting for finality: tx=%s sleeping=%.1fs", tx_hash, poll_interval) - await asyncio.sleep(poll_interval) - - -def sign_tx(w3: Web3, tx: dict, private_key: str) -> SignedTransaction: - signed_tx =w3.eth.account.sign_transaction(tx, private_key=private_key) - return signed_tx - - -async def send_tx(w3: Web3, signed_tx: SignedTransaction) -> str: - tx_hash = await w3.eth.send_raw_transaction(signed_tx.raw_transaction) - return tx_hash.to_0x_hex() - - -async def estimate_fees(w3, percentiles: list[int], blocks=20, default_tip=10_000): - fee_history = await w3.eth.fee_history(blocks, "pending", percentiles) - base_fees = fee_history["baseFeePerGas"] - rewards = fee_history["reward"] - - # Calculate average priority fees for each percentile - avg_priority_fees = [] - for i in range(len(percentiles)): - nonzero_rewards = [r[i] for r in rewards if len(r) > i and r[i] > 0] - if nonzero_rewards: - estimated_tip = sum(nonzero_rewards) // len(nonzero_rewards) - else: - estimated_tip = default_tip - avg_priority_fees.append(estimated_tip) - - # Use the latest base fee - latest_base_fee = base_fees[-1] - - # Calculate max fees - fee_estimations = [] - for priority_fee in avg_priority_fees: - max_fee = int((latest_base_fee + priority_fee) * GAS_FEE_BUFFER) - fee_estimations.append({"maxFeePerGas": max_fee, "maxPriorityFeePerGas": priority_fee}) - - return fee_estimations - - -async def iter_events( - w3: Web3, - filter_params: dict, - *, - condition: Callable[[AttributeDict], bool] = lambda _: True, - max_block_range: int = 10_000, - poll_interval: float = 5.0, - timeout: float | None = None, - logger: Logger, -) -> Generator[AttributeDict, None, None]: - """Stream matching logs over a fixed or live block window. Optionally raises TimeoutError.""" - - original_filter_params = filter_params.copy() # return original in TimeoutError - if (cursor := filter_params["fromBlock"]) == "latest": - cursor = await w3.eth.block_number - - start_block = cursor - filter_params["toBlock"] = filter_params.get("toBlock", "latest") - fixed_ceiling = None if filter_params["toBlock"] == "latest" else filter_params["toBlock"] - - deadline = None if timeout is None else time.monotonic() + timeout - while True: - if deadline and time.monotonic() > deadline: - msg = f"Timed out waiting for events after scanning blocks {start_block}-{cursor}" - logger.warning(msg) - raise TimeoutError(f"{msg}: filter_params: {original_filter_params}") - upper = fixed_ceiling or await w3.eth.block_number - if cursor <= upper: - end = min(upper, cursor + max_block_range - 1) - filter_params["fromBlock"] = hex(cursor) - filter_params["toBlock"] = hex(end) - # For example, when rotating providers are out of sync - retry_get_logs = exp_backoff_retry(w3.eth.get_logs, attempts=EVENT_LOG_RETRIES) - logs = await retry_get_logs(filter_params=filter_params) - logger.debug(f"Scanned {cursor} - {end}: {len(logs)} logs") - for log in filter(condition, logs): - yield log - cursor = end + 1 # bounds are inclusive - - if fixed_ceiling and cursor > fixed_ceiling: - raise StopIteration - - await asyncio.sleep(poll_interval) - - -async def wait_for_event( - w3: Web3, - filter_params: dict, - *, - condition: Callable[[AttributeDict], bool] = lambda _: True, - max_block_range: int = 10_000, - poll_interval: float = 5.0, - timeout: float = 300.0, - logger: Logger, -) -> AttributeDict: - """Return the first log from iter_events, or raise TimeoutError after `timeout` seconds.""" - - return await anext(iter_events(**locals())) - - -def make_filter_params( - event: ContractEvent, - from_block: int | Literal["latest"], - to_block: int | Literal["latest"] = "latest", - argument_filters: dict | None = None, -) -> dict: - """ - Function to create an eth_getLogs compatible filter_params for this event without using .create_filter. - event.create_filter uses eth_newFilter (a "push"), which not all RPC endpoints support. - """ - - argument_filters = argument_filters or {} - filter_params = event._get_event_filter_params( - fromBlock=from_block, - toBlock=to_block, - argument_filters=argument_filters, - abi=event.abi, - ) - filter_params["topics"] = tuple(filter_params["topics"]) - address = filter_params["address"] - if isinstance(address, str): - filter_params["address"] = Web3.to_checksum_address(address) - elif isinstance(address, (list, tuple)) and len(address) == 1: - filter_params["address"] = Web3.to_checksum_address(address[0]) - else: - raise ValueError(f"Unexpected address filter: {address!r}") - - return filter_params From 07047215a5eea390676dbb7e30c1ad6439f7b4d4 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Thu, 14 Aug 2025 13:02:30 +0200 Subject: [PATCH 043/101] fix: models --- derive_client/data_types/models.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/derive_client/data_types/models.py b/derive_client/data_types/models.py index e86d868f..ae0e373d 100644 --- a/derive_client/data_types/models.py +++ b/derive_client/data_types/models.py @@ -215,18 +215,6 @@ class BridgeContext: source_chain: ChainID target_chain: ChainID - # async def init_chains(self): - # self._source_chain = ChainID(await self.source_w3.eth.chain_id) - # self._target_chain = ChainID(await self.target_w3.eth.chain_id) - - # @property - # def source_chain(self) -> ChainID: - # return self._source_chain - - # @property - # def target_chain(self) -> ChainID: - # return self._target_chain - @dataclass class BridgeTxDetails: @@ -286,7 +274,7 @@ class BridgeTxResult: target_chain: ChainID source_tx: TxResult tx_details: BridgeTxDetails - target_from_block: int | None = None + target_from_block: int event_id: str | None = None target_tx: TxResult | None = None From 94cbf373597e41ca9eab27221a82bc907bd47bb8 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Thu, 14 Aug 2025 15:31:00 +0200 Subject: [PATCH 044/101] feat: BridgeClient.create --- derive_client/_bridge/client.py | 37 +++++++++++++++++++++++++--- derive_client/clients/base_client.py | 14 ++++++----- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index b7482d80..b9d64c93 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -62,7 +62,7 @@ PartialBridgeResult, ) from derive_client.utils import get_prod_derive_addresses -from .utils.w3 import ( +from .w3 import ( build_standard_transaction, get_contract, get_w3_connection, @@ -114,6 +114,8 @@ def _get_min_fees( class BridgeClient: def __init__(self, env: Environment, chain_id: ChainID, account: Account, wallet: Address, logger: Logger): + """Private init - use Bridge.create() instead.""" + # raise RuntimeError("Use Bridge.create() async factory method instead of direct instantiation.") if not env == Environment.PROD: raise RuntimeError(f"Bridging is not supported in the {env.name} environment.") self.config = CONFIGS[env] @@ -123,8 +125,8 @@ def __init__(self, env: Environment, chain_id: ChainID, account: Account, wallet self.derive_addresses = get_prod_derive_addresses() self.light_account = _load_light_account(w3=self.derive_w3, wallet=wallet) self.logger = logger - self.owner = self.account.address self.remote_chain_id = chain_id + self.owner = self.account.address if self.owner != self.account.address: raise BridgePrimarySignerRequiredError( "Bridging disabled for secondary session-key signers: old-style assets " @@ -134,6 +136,35 @@ def __init__(self, env: Environment, chain_id: ChainID, account: Account, wallet "primary wallet owner." ) + @classmethod + async def create(cls, env: Environment, chain_id: ChainID, account: Account, wallet: Address, logger: Logger): + """Async factory method to create and validate a Bridge instance.""" + if not env == Environment.PROD: + raise RuntimeError(f"Bridging is not supported in the {env.name} environment.") + + instance = cls.__new__(cls) + instance.config = CONFIGS[env] + instance.derive_w3 = get_w3_connection(chain_id=ChainID.DERIVE, logger=logger) + instance.remote_w3 = get_w3_connection(chain_id=chain_id, logger=logger) + instance.account = account + instance.derive_addresses = get_prod_derive_addresses() + instance.light_account = _load_light_account(w3=instance.derive_w3, wallet=wallet) + instance.logger = logger + instance.remote_chain_id = chain_id + + owner = await instance.light_account.functions.owner().call() + if owner != account.address: + raise BridgePrimarySignerRequiredError( + "Bridging disabled for secondary session-key signers: old-style assets " + "(USDC, USDT) on Derive cannot specify a custom receiver. Using a " + "secondary signer routes funds to the session key's contract instead of " + "the primary owner's. Please run all bridge operations with the " + "primary wallet owner." + ) + instance.owner = owner + + return instance + # @property # def remote_chain_id(self) -> ChainID: # return ChainID(self.remote_w3.eth.chain_id) @@ -146,7 +177,7 @@ def wallet(self) -> Address: # @functools.cached_property # def owner(self) -> Address: # """Owner of smart contract funding wallet, must be the same as self.account.address.""" - # return await self.light_account.functions.owner().call() + # return self.light_account.functions.owner().call() @property def private_key(self) -> str: diff --git a/derive_client/clients/base_client.py b/derive_client/clients/base_client.py index f7621bb3..3c3af674 100644 --- a/derive_client/clients/base_client.py +++ b/derive_client/clients/base_client.py @@ -162,19 +162,20 @@ def deposit_to_derive_result( """ amount = int(amount * 10 ** TOKEN_DECIMALS[UnderlyingCurrency[currency.name.upper()]]) - client = BridgeClient(self.env, chain_id, account=self.signer, wallet=self.wallet, logger=self.logger) + # client = BridgeClient(self.env, chain_id, account=self.signer, wallet=self.wallet, logger=self.logger) # prepared_tx = client.prepare_deposit(amount=amount, currency=currency) # tx_result = client.submit_bridge_tx(prepared_tx) # return client.poll_bridge_progress(tx_result=tx_result) - async def _run(): + async def _run(account, wallet, logger): + client = await BridgeClient.create(self.env, chain_id, account=account, wallet=wallet, logger=logger) prepared_tx = await client.prepare_deposit(amount=amount, currency=currency) tx_result = await client.submit_bridge_tx(prepared_tx) return await client.poll_bridge_progress(tx_result=tx_result) - return asyncio.run(_run()) + return asyncio.run(_run(account=self.signer, wallet=self.wallet, logger=self.logger)) def deposit_to_derive( self, @@ -221,19 +222,20 @@ def withdraw_from_derive_result( """ amount = int(amount * 10 ** TOKEN_DECIMALS[UnderlyingCurrency[currency.name.upper()]]) - client = BridgeClient(self.env, chain_id, account=self.signer, wallet=self.wallet, logger=self.logger) + # client = BridgeClient(self.env, chain_id, account=self.signer, wallet=self.wallet, logger=self.logger) # prepared_tx = client.prepare_withdrawal(amount=amount, currency=currency) # tx_result = client.submit_bridge_tx(prepared_tx=prepared_tx) # return client.poll_bridge_progress(tx_result=tx_result) - async def _run(): + async def _run(account, wallet, logger): + client = await BridgeClient.create(self.env, chain_id, account=account, wallet=wallet, logger=logger) prepared_tx = await client.prepare_withdrawal(amount=amount, currency=currency) tx_result = await client.submit_bridge_tx(prepared_tx) return await client.poll_bridge_progress(tx_result=tx_result) - return asyncio.run(_run()) + return asyncio.run(_run(account=self.signer, wallet=self.wallet, logger=self.logger)) def withdraw_from_derive( self, From 455c367149b95e853faf718ac57514ec78a91831 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Thu, 14 Aug 2025 15:40:57 +0200 Subject: [PATCH 045/101] fix: models --- derive_client/data_types/models.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/derive_client/data_types/models.py b/derive_client/data_types/models.py index ae0e373d..54c1edbd 100644 --- a/derive_client/data_types/models.py +++ b/derive_client/data_types/models.py @@ -12,9 +12,8 @@ from pydantic.dataclasses import dataclass from pydantic_core import core_schema from web3 import Web3, AsyncWeb3 -from web3.contract import Contract, AsyncContract +from web3.contract import AsyncContract from web3.contract.async_contract import AsyncContractEvent -from web3.contract.contract import ContractEvent from web3.datastructures import AttributeDict from .enums import BridgeType, ChainID, Currency, DeriveTxStatus, MainnetCurrency, MarginType, SessionKeyScope, TxStatus From 2a8a8af3c802a091bacb21a7b3f79ec26759b5c5 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Thu, 14 Aug 2025 15:45:47 +0200 Subject: [PATCH 046/101] fix: Web3 -> AsyncWeb3 in _bridge.w3 --- derive_client/_bridge/w3.py | 35 ++++++++++++++++------------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/derive_client/_bridge/w3.py b/derive_client/_bridge/w3.py index 760e8d1b..28c0bb09 100644 --- a/derive_client/_bridge/w3.py +++ b/derive_client/_bridge/w3.py @@ -1,17 +1,14 @@ import asyncio -import functools import heapq import json import time from logging import Logger -from pathlib import Path from typing import Any, Callable, Generator, Literal -import yaml from eth_account import Account from eth_account.datastructures import SignedTransaction from requests import RequestException -from web3 import Web3, AsyncWeb3, AsyncHTTPProvider +from web3 import AsyncWeb3, AsyncHTTPProvider from web3.contract import Contract from web3.contract.contract import ContractEvent from web3.datastructures import AttributeDict @@ -40,7 +37,7 @@ def make_rotating_provider_middleware( initial_backoff: float = 1.0, max_backoff: float = 600.0, logger: Logger, -) -> Callable[[Callable[[str, Any], Any], Web3], Callable[[str, Any], Any]]: +) -> Callable[[Callable[[str, Any], Any], AsyncWeb3], Callable[[str, Any], Any]]: """ v6.11-style middleware: - round-robin via a min-heap of `next_available` times @@ -51,7 +48,7 @@ def make_rotating_provider_middleware( heapq.heapify(heap) lock = asyncio.Lock() - async def middleware_factory(make_request: Callable[[str, Any], Any], w3: Web3) -> Callable[[str, Any], Any]: + async def middleware_factory(make_request: Callable[[str, Any], Any], w3: AsyncWeb3) -> Callable[[str, Any], Any]: async def rotating_backoff(method: str, params: Any) -> Any: now = time.monotonic() @@ -128,7 +125,7 @@ def get_w3_connection( *, rpc_endpoints: RPCEndpoints | None = None, logger: Logger | None = None, -) -> Web3: +) -> AsyncWeb3: rpc_endpoints = rpc_endpoints or load_rpc_endpoints(DEFAULT_RPC_ENDPOINTS) providers = [AsyncHTTPProvider(str(url)) for url in rpc_endpoints[chain_id]] @@ -146,17 +143,17 @@ def get_w3_connection( return w3 -def get_contract(w3: Web3, address: str, abi: list) -> Contract: - return w3.eth.contract(address=Web3.to_checksum_address(address), abi=abi) +def get_contract(w3: AsyncWeb3, address: str, abi: list) -> Contract: + return w3.eth.contract(address=AsyncWeb3.to_checksum_address(address), abi=abi) -def get_erc20_contract(w3: Web3, token_address: str) -> Contract: +def get_erc20_contract(w3: AsyncWeb3, token_address: str) -> Contract: erc20_abi_path = ABI_DATA_DIR / "erc20.json" abi = json.loads(erc20_abi_path.read_text()) return get_contract(w3=w3, address=token_address, abi=abi) -async def simulate_tx(w3: Web3, tx: dict, account: Account) -> dict: +async def simulate_tx(w3: AsyncWeb3, tx: dict, account: Account) -> dict: balance = await w3.eth.get_balance(account.address) max_fee_per_gas = tx["maxFeePerGas"] gas_limit = tx["gas"] @@ -176,7 +173,7 @@ async def simulate_tx(w3: Web3, tx: dict, account: Account) -> dict: async def build_standard_transaction( func, account: Account, - w3: Web3, + w3: AsyncWeb3, value: int = 0, gas_blocks: int = 100, gas_percentile: int = 99, @@ -204,7 +201,7 @@ async def build_standard_transaction( async def wait_for_tx_finality( - w3: Web3, + w3: AsyncWeb3, tx_hash: str, logger: Logger, finality_blocks: int = 10, @@ -295,12 +292,12 @@ async def wait_for_tx_finality( await asyncio.sleep(poll_interval) -def sign_tx(w3: Web3, tx: dict, private_key: str) -> SignedTransaction: +def sign_tx(w3: AsyncWeb3, tx: dict, private_key: str) -> SignedTransaction: signed_tx =w3.eth.account.sign_transaction(tx, private_key=private_key) return signed_tx -async def send_tx(w3: Web3, signed_tx: SignedTransaction) -> str: +async def send_tx(w3: AsyncWeb3, signed_tx: SignedTransaction) -> str: tx_hash = await w3.eth.send_raw_transaction(signed_tx.raw_transaction) return tx_hash.to_0x_hex() @@ -333,7 +330,7 @@ async def estimate_fees(w3, percentiles: list[int], blocks=20, default_tip=10_00 async def iter_events( - w3: Web3, + w3: AsyncWeb3, filter_params: dict, *, condition: Callable[[AttributeDict], bool] = lambda _: True, @@ -378,7 +375,7 @@ async def iter_events( async def wait_for_event( - w3: Web3, + w3: AsyncWeb3, filter_params: dict, *, condition: Callable[[AttributeDict], bool] = lambda _: True, @@ -413,9 +410,9 @@ def make_filter_params( filter_params["topics"] = tuple(filter_params["topics"]) address = filter_params["address"] if isinstance(address, str): - filter_params["address"] = Web3.to_checksum_address(address) + filter_params["address"] = AsyncWeb3.to_checksum_address(address) elif isinstance(address, (list, tuple)) and len(address) == 1: - filter_params["address"] = Web3.to_checksum_address(address[0]) + filter_params["address"] = AsyncWeb3.to_checksum_address(address[0]) else: raise ValueError(f"Unexpected address filter: {address!r}") From a94795a9dc72484f9f0719bca7efe96982035ba5 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Thu, 14 Aug 2025 17:03:49 +0200 Subject: [PATCH 047/101] fix: async get_w3_connection --- derive_client/_bridge/w3.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/derive_client/_bridge/w3.py b/derive_client/_bridge/w3.py index 28c0bb09..308e13d5 100644 --- a/derive_client/_bridge/w3.py +++ b/derive_client/_bridge/w3.py @@ -9,10 +9,8 @@ from eth_account.datastructures import SignedTransaction from requests import RequestException from web3 import AsyncWeb3, AsyncHTTPProvider -from web3.contract import Contract -from web3.contract.contract import ContractEvent +from web3.contract.async_contract import AsyncContract, AsyncContractEvent from web3.datastructures import AttributeDict -from web3.providers.rpc import HTTPProvider from derive_client.constants import ABI_DATA_DIR, DEFAULT_RPC_ENDPOINTS, GAS_FEE_BUFFER, GAS_LIMIT_BUFFER from derive_client.data_types import ChainID, RPCEndpoints @@ -32,7 +30,7 @@ def make_rotating_provider_middleware( - endpoints: list[HTTPProvider], + endpoints: list[AsyncHTTPProvider], *, initial_backoff: float = 1.0, max_backoff: float = 600.0, @@ -50,6 +48,7 @@ def make_rotating_provider_middleware( async def middleware_factory(make_request: Callable[[str, Any], Any], w3: AsyncWeb3) -> Callable[[str, Any], Any]: async def rotating_backoff(method: str, params: Any) -> Any: + now = time.monotonic() while True: @@ -126,28 +125,33 @@ def get_w3_connection( rpc_endpoints: RPCEndpoints | None = None, logger: Logger | None = None, ) -> AsyncWeb3: + rpc_endpoints = rpc_endpoints or load_rpc_endpoints(DEFAULT_RPC_ENDPOINTS) providers = [AsyncHTTPProvider(str(url)) for url in rpc_endpoints[chain_id]] logger = logger or get_logger() # NOTE: Initial provider is a no-op once middleware is in place - w3 = AsyncWeb3(providers[0]) + # NOTE: If you don't set a dummy provider, bad things will happen! + provider = AsyncHTTPProvider() + w3 = AsyncWeb3(provider) + rotator = make_rotating_provider_middleware( providers, initial_backoff=1.0, max_backoff=600.0, logger=logger, ) - w3.middleware_onion.add(rotator) + w3.middleware_onion.add(rotator, name="rotating_provider") + return w3 -def get_contract(w3: AsyncWeb3, address: str, abi: list) -> Contract: +def get_contract(w3: AsyncWeb3, address: str, abi: list) -> AsyncContract: return w3.eth.contract(address=AsyncWeb3.to_checksum_address(address), abi=abi) -def get_erc20_contract(w3: AsyncWeb3, token_address: str) -> Contract: +def get_erc20_contract(w3: AsyncWeb3, token_address: str) -> AsyncContract: erc20_abi_path = ABI_DATA_DIR / "erc20.json" abi = json.loads(erc20_abi_path.read_text()) return get_contract(w3=w3, address=token_address, abi=abi) @@ -169,7 +173,7 @@ async def simulate_tx(w3: AsyncWeb3, tx: dict, account: Account) -> dict: return tx -# @exp_backoff_retry +@exp_backoff_retry async def build_standard_transaction( func, account: Account, @@ -390,7 +394,7 @@ async def wait_for_event( def make_filter_params( - event: ContractEvent, + event: AsyncContractEvent, from_block: int | Literal["latest"], to_block: int | Literal["latest"] = "latest", argument_filters: dict | None = None, From 86d167f5a8bf9dfd88e75167aa400e12bac5a11a Mon Sep 17 00:00:00 2001 From: zarathustra Date: Thu, 14 Aug 2025 17:09:48 +0200 Subject: [PATCH 048/101] refactor: merge transactions.py into w3.py --- derive_client/_bridge/client.py | 6 ++- derive_client/_bridge/transaction.py | 58 ------------------------- derive_client/_bridge/w3.py | 65 +++++++++++++++++++++++++--- 3 files changed, 63 insertions(+), 66 deletions(-) delete mode 100644 derive_client/_bridge/transaction.py diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index b9d64c93..7f29ea65 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -15,7 +15,6 @@ from web3.datastructures import AttributeDict from web3.types import HexBytes, LogReceipt, TxReceipt -from derive_client._bridge.transaction import ensure_token_allowance, ensure_token_balance from derive_client.constants import ( CONFIGS, CONTROLLER_ABI_PATH, @@ -62,8 +61,11 @@ PartialBridgeResult, ) from derive_client.utils import get_prod_derive_addresses + from .w3 import ( build_standard_transaction, + ensure_token_allowance, + ensure_token_balance, get_contract, get_w3_connection, make_filter_params, @@ -609,7 +611,7 @@ def _prepare_new_style_deposit(self, token_data: NonMintableTokenData, amount: i vault_contract = _load_vault_contract(w3=self.remote_w3, token_data=token_data) connector = token_data.connectors[ChainID.DERIVE][TARGET_SPEED] - fees_func =_get_min_fees(bridge_contract=vault_contract, connector=connector, token_data=token_data) + fees_func = _get_min_fees(bridge_contract=vault_contract, connector=connector, token_data=token_data) func = vault_contract.functions.bridge( receiver_=self.wallet, amount_=amount, diff --git a/derive_client/_bridge/transaction.py b/derive_client/_bridge/transaction.py deleted file mode 100644 index 5591d8a1..00000000 --- a/derive_client/_bridge/transaction.py +++ /dev/null @@ -1,58 +0,0 @@ -from logging import Logger - -from eth_account import Account -from web3 import Web3 -from web3.contract import Contract - -from derive_client.data_types import Address, TxStatus -from derive_client.exceptions import InsufficientTokenBalance -from .w3 import build_standard_transaction, send_tx, sign_tx, wait_for_tx_finality - - -async def ensure_token_balance(token_contract: Contract, owner: Address, amount: int): - balance = await token_contract.functions.balanceOf(owner).call() - if amount > balance: - raise InsufficientTokenBalance( - f"Not enough tokens to withdraw: {amount} < {balance} ({(balance / amount * 100):.2f}%)" - ) - - -async def ensure_token_allowance( - w3: Web3, - token_contract: Contract, - owner: Address, - spender: Address, - amount: int, - private_key: str, - logger: Logger, -): - allowance = await token_contract.functions.allowance(owner, spender).call() - if amount > allowance: - logger.info(f"Increasing allowance from {allowance} to {amount}") - await _increase_token_allowance( - w3=w3, - from_account=Account.from_key(private_key), - erc20_contract=token_contract, - spender=spender, - amount=amount, - private_key=private_key, - logger=logger, - ) - - -async def _increase_token_allowance( - w3: Web3, - from_account: Account, - erc20_contract: Contract, - spender: Address, - amount: int, - private_key: str, - logger: Logger, -) -> None: - func = erc20_contract.functions.approve(spender, amount) - tx = await build_standard_transaction(func=func, account=from_account, w3=w3) - signed_tx = sign_tx(w3=w3, tx=tx, private_key=private_key) - tx_hash = await send_tx(w3=w3, signed_tx=signed_tx) - tx_receipt = await wait_for_tx_finality(w3=w3, tx_hash=tx_hash, logger=logger) - if tx_receipt.status != TxStatus.SUCCESS: - raise RuntimeError("approve() failed") diff --git a/derive_client/_bridge/w3.py b/derive_client/_bridge/w3.py index 308e13d5..9a14f0ab 100644 --- a/derive_client/_bridge/w3.py +++ b/derive_client/_bridge/w3.py @@ -8,23 +8,24 @@ from eth_account import Account from eth_account.datastructures import SignedTransaction from requests import RequestException -from web3 import AsyncWeb3, AsyncHTTPProvider +from web3 import AsyncHTTPProvider, AsyncWeb3, Web3 +from web3.contract import Contract from web3.contract.async_contract import AsyncContract, AsyncContractEvent from web3.datastructures import AttributeDict from derive_client.constants import ABI_DATA_DIR, DEFAULT_RPC_ENDPOINTS, GAS_FEE_BUFFER, GAS_LIMIT_BUFFER -from derive_client.data_types import ChainID, RPCEndpoints +from derive_client.data_types import Address, ChainID, RPCEndpoints, TxStatus from derive_client.exceptions import ( FinalityTimeout, InsufficientNativeBalance, + InsufficientTokenBalance, NoAvailableRPC, TransactionDropped, TxPendingTimeout, ) from derive_client.utils.logger import get_logger from derive_client.utils.retry import exp_backoff_retry -from derive_client.utils.w3 import load_rpc_endpoints, EndpointState - +from derive_client.utils.w3 import EndpointState, load_rpc_endpoints EVENT_LOG_RETRIES = 10 @@ -157,6 +158,55 @@ def get_erc20_contract(w3: AsyncWeb3, token_address: str) -> AsyncContract: return get_contract(w3=w3, address=token_address, abi=abi) +async def ensure_token_balance(token_contract: Contract, owner: Address, amount: int): + balance = await token_contract.functions.balanceOf(owner).call() + if amount > balance: + raise InsufficientTokenBalance( + f"Not enough tokens to withdraw: {amount} < {balance} ({(balance / amount * 100):.2f}%)" + ) + + +async def ensure_token_allowance( + w3: Web3, + token_contract: Contract, + owner: Address, + spender: Address, + amount: int, + private_key: str, + logger: Logger, +): + allowance = await token_contract.functions.allowance(owner, spender).call() + if amount > allowance: + logger.info(f"Increasing allowance from {allowance} to {amount}") + await _increase_token_allowance( + w3=w3, + from_account=Account.from_key(private_key), + erc20_contract=token_contract, + spender=spender, + amount=amount, + private_key=private_key, + logger=logger, + ) + + +async def _increase_token_allowance( + w3: Web3, + from_account: Account, + erc20_contract: Contract, + spender: Address, + amount: int, + private_key: str, + logger: Logger, +) -> None: + func = erc20_contract.functions.approve(spender, amount) + tx = await build_standard_transaction(func=func, account=from_account, w3=w3) + signed_tx = sign_tx(w3=w3, tx=tx, private_key=private_key) + tx_hash = await send_tx(w3=w3, signed_tx=signed_tx) + tx_receipt = await wait_for_tx_finality(w3=w3, tx_hash=tx_hash, logger=logger) + if tx_receipt.status != TxStatus.SUCCESS: + raise RuntimeError("approve() failed") + + async def simulate_tx(w3: AsyncWeb3, tx: dict, account: Account) -> dict: balance = await w3.eth.get_balance(account.address) max_fee_per_gas = tx["maxFeePerGas"] @@ -247,7 +297,10 @@ async def wait_for_tx_finality( # blockNumber can change as tx gets reorged into different blocks try: - if receipt is not None and (block_number := await w3.eth.block_number) >= receipt.blockNumber + finality_blocks: + if ( + receipt is not None + and (block_number := await w3.eth.block_number) >= receipt.blockNumber + finality_blocks + ): return receipt except Exception as exc: msg = "Failed to fetch block_number trying to assess finality of tx_hash=%s" @@ -297,7 +350,7 @@ async def wait_for_tx_finality( def sign_tx(w3: AsyncWeb3, tx: dict, private_key: str) -> SignedTransaction: - signed_tx =w3.eth.account.sign_transaction(tx, private_key=private_key) + signed_tx = w3.eth.account.sign_transaction(tx, private_key=private_key) return signed_tx From 31717ec0187713c74c9713c14ff369350cf1d769 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Thu, 14 Aug 2025 17:12:07 +0200 Subject: [PATCH 049/101] fix: use TYPE_CHECKING for BridgeTxResult import --- derive_client/exceptions.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/derive_client/exceptions.py b/derive_client/exceptions.py index bbe689fe..5dae0ae0 100644 --- a/derive_client/exceptions.py +++ b/derive_client/exceptions.py @@ -1,6 +1,11 @@ """Custom Exception classes.""" -from typing import Any +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from derive_client.data_types import BridgeTxResult class ApiException(Exception): From 3f26c0cc86526814f96bc66f7c2c386701dcf7ba Mon Sep 17 00:00:00 2001 From: zarathustra Date: Thu, 14 Aug 2025 17:33:52 +0200 Subject: [PATCH 050/101] chore: cleanup BridgeClient --- derive_client/_bridge/client.py | 30 +----------------------------- derive_client/utils/__init__.py | 1 - 2 files changed, 1 insertion(+), 30 deletions(-) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index 7f29ea65..b166220f 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -117,26 +117,7 @@ def _get_min_fees( class BridgeClient: def __init__(self, env: Environment, chain_id: ChainID, account: Account, wallet: Address, logger: Logger): """Private init - use Bridge.create() instead.""" - # raise RuntimeError("Use Bridge.create() async factory method instead of direct instantiation.") - if not env == Environment.PROD: - raise RuntimeError(f"Bridging is not supported in the {env.name} environment.") - self.config = CONFIGS[env] - self.derive_w3 = get_w3_connection(chain_id=ChainID.DERIVE, logger=logger) - self.remote_w3 = get_w3_connection(chain_id=chain_id, logger=logger) - self.account = account - self.derive_addresses = get_prod_derive_addresses() - self.light_account = _load_light_account(w3=self.derive_w3, wallet=wallet) - self.logger = logger - self.remote_chain_id = chain_id - self.owner = self.account.address - if self.owner != self.account.address: - raise BridgePrimarySignerRequiredError( - "Bridging disabled for secondary session-key signers: old-style assets " - "(USDC, USDT) on Derive cannot specify a custom receiver. Using a " - "secondary signer routes funds to the session key's contract instead of " - "the primary owner's. Please run all bridge operations with the " - "primary wallet owner." - ) + raise RuntimeError("Use Bridge.create() async factory method instead of direct instantiation.") @classmethod async def create(cls, env: Environment, chain_id: ChainID, account: Account, wallet: Address, logger: Logger): @@ -167,20 +148,11 @@ async def create(cls, env: Environment, chain_id: ChainID, account: Account, wal return instance - # @property - # def remote_chain_id(self) -> ChainID: - # return ChainID(self.remote_w3.eth.chain_id) - @property def wallet(self) -> Address: """Smart contract funding wallet.""" return self.light_account.address - # @functools.cached_property - # def owner(self) -> Address: - # """Owner of smart contract funding wallet, must be the same as self.account.address.""" - # return self.light_account.functions.owner().call() - @property def private_key(self) -> str: """Private key of the owner (EOA) of the smart contract funding account.""" diff --git a/derive_client/utils/__init__.py b/derive_client/utils/__init__.py index 3c32ac70..a2c40e91 100644 --- a/derive_client/utils/__init__.py +++ b/derive_client/utils/__init__.py @@ -7,7 +7,6 @@ from .unwrap import unwrap_or_raise from .w3 import get_w3_connection, load_rpc_endpoints - __all__ = [ "get_logger", "get_prod_derive_addresses", From b4ec2f2d383a905bb642af28f915664357afe387 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Thu, 14 Aug 2025 19:25:38 +0200 Subject: [PATCH 051/101] chore: update BaseClient --- derive_client/clients/base_client.py | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/derive_client/clients/base_client.py b/derive_client/clients/base_client.py index 3c3af674..b2a0c1ae 100644 --- a/derive_client/clients/base_client.py +++ b/derive_client/clients/base_client.py @@ -162,19 +162,13 @@ def deposit_to_derive_result( """ amount = int(amount * 10 ** TOKEN_DECIMALS[UnderlyingCurrency[currency.name.upper()]]) - # client = BridgeClient(self.env, chain_id, account=self.signer, wallet=self.wallet, logger=self.logger) - - # prepared_tx = client.prepare_deposit(amount=amount, currency=currency) - # tx_result = client.submit_bridge_tx(prepared_tx) - - # return client.poll_bridge_progress(tx_result=tx_result) async def _run(account, wallet, logger): client = await BridgeClient.create(self.env, chain_id, account=account, wallet=wallet, logger=logger) prepared_tx = await client.prepare_deposit(amount=amount, currency=currency) tx_result = await client.submit_bridge_tx(prepared_tx) return await client.poll_bridge_progress(tx_result=tx_result) - + return asyncio.run(_run(account=self.signer, wallet=self.wallet, logger=self.logger)) def deposit_to_derive( @@ -222,19 +216,13 @@ def withdraw_from_derive_result( """ amount = int(amount * 10 ** TOKEN_DECIMALS[UnderlyingCurrency[currency.name.upper()]]) - # client = BridgeClient(self.env, chain_id, account=self.signer, wallet=self.wallet, logger=self.logger) - - # prepared_tx = client.prepare_withdrawal(amount=amount, currency=currency) - # tx_result = client.submit_bridge_tx(prepared_tx=prepared_tx) - - # return client.poll_bridge_progress(tx_result=tx_result) async def _run(account, wallet, logger): client = await BridgeClient.create(self.env, chain_id, account=account, wallet=wallet, logger=logger) prepared_tx = await client.prepare_withdrawal(amount=amount, currency=currency) tx_result = await client.submit_bridge_tx(prepared_tx) return await client.poll_bridge_progress(tx_result=tx_result) - + return asyncio.run(_run(account=self.signer, wallet=self.wallet, logger=self.logger)) def withdraw_from_derive( @@ -275,8 +263,12 @@ def poll_bridge_progress_result(self, tx_result: BridgeTxResult) -> Result[Bridg """ chain_id = tx_result.source_chain if tx_result.source_chain != ChainID.DERIVE else tx_result.target_chain - client = BridgeClient(self.env, chain_id, account=self.signer, wallet=self.wallet, logger=self.logger) - return client.poll_bridge_progress(tx_result=tx_result) + + async def _run(account, wallet, logger): + client = await BridgeClient.create(self.env, chain_id, account=account, wallet=wallet, logger=logger) + return await client.poll_bridge_progress(tx_result=tx_result) + + return asyncio.run(_run(account=self.signer, wallet=self.wallet, logger=self.logger)) def poll_bridge_progress(self, tx_result: BridgeTxResult) -> BridgeTxResult: """ From d57a6b2e91d11c50606aebe656e7037b20343847 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Thu, 14 Aug 2025 19:27:47 +0200 Subject: [PATCH 052/101] fix: type annotations --- derive_client/_bridge/w3.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/derive_client/_bridge/w3.py b/derive_client/_bridge/w3.py index 9a14f0ab..09d23d24 100644 --- a/derive_client/_bridge/w3.py +++ b/derive_client/_bridge/w3.py @@ -8,7 +8,7 @@ from eth_account import Account from eth_account.datastructures import SignedTransaction from requests import RequestException -from web3 import AsyncHTTPProvider, AsyncWeb3, Web3 +from web3 import AsyncHTTPProvider, AsyncWeb3 from web3.contract import Contract from web3.contract.async_contract import AsyncContract, AsyncContractEvent from web3.datastructures import AttributeDict @@ -167,7 +167,7 @@ async def ensure_token_balance(token_contract: Contract, owner: Address, amount: async def ensure_token_allowance( - w3: Web3, + w3: AsyncWeb3, token_contract: Contract, owner: Address, spender: Address, @@ -190,7 +190,7 @@ async def ensure_token_allowance( async def _increase_token_allowance( - w3: Web3, + w3: AsyncWeb3, from_account: Account, erc20_contract: Contract, spender: Address, From 33658f32e0dd4eb67c0d54c032e5c0f1930590e9 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Thu, 14 Aug 2025 20:08:26 +0200 Subject: [PATCH 053/101] feat: make BridgeClient init indepedent of remote chain --- derive_client/_bridge/client.py | 219 +++++++++++++-------------- derive_client/_bridge/w3.py | 4 + derive_client/clients/base_client.py | 23 ++- derive_client/data_types/models.py | 19 ++- 4 files changed, 136 insertions(+), 129 deletions(-) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index b166220f..bb519d1d 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -67,7 +67,7 @@ ensure_token_allowance, ensure_token_balance, get_contract, - get_w3_connection, + get_w3_connections, make_filter_params, send_tx, sign_tx, @@ -115,43 +115,23 @@ def _get_min_fees( class BridgeClient: - def __init__(self, env: Environment, chain_id: ChainID, account: Account, wallet: Address, logger: Logger): - """Private init - use Bridge.create() instead.""" - raise RuntimeError("Use Bridge.create() async factory method instead of direct instantiation.") + def __init__(self, env: Environment, account: Account, wallet: Address, logger: Logger): + """Synchronous constructor that performs minimal, non-blocking setup.""" - @classmethod - async def create(cls, env: Environment, chain_id: ChainID, account: Account, wallet: Address, logger: Logger): - """Async factory method to create and validate a Bridge instance.""" if not env == Environment.PROD: raise RuntimeError(f"Bridging is not supported in the {env.name} environment.") - instance = cls.__new__(cls) - instance.config = CONFIGS[env] - instance.derive_w3 = get_w3_connection(chain_id=ChainID.DERIVE, logger=logger) - instance.remote_w3 = get_w3_connection(chain_id=chain_id, logger=logger) - instance.account = account - instance.derive_addresses = get_prod_derive_addresses() - instance.light_account = _load_light_account(w3=instance.derive_w3, wallet=wallet) - instance.logger = logger - instance.remote_chain_id = chain_id - - owner = await instance.light_account.functions.owner().call() - if owner != account.address: - raise BridgePrimarySignerRequiredError( - "Bridging disabled for secondary session-key signers: old-style assets " - "(USDC, USDT) on Derive cannot specify a custom receiver. Using a " - "secondary signer routes funds to the session key's contract instead of " - "the primary owner's. Please run all bridge operations with the " - "primary wallet owner." - ) - instance.owner = owner - - return instance + self.config = CONFIGS[env] + self.account = account + self.owner = account.address + self.wallet = wallet + self.derive_addresses = get_prod_derive_addresses() + self.w3s = get_w3_connections(logger=logger) + self.logger = logger @property - def wallet(self) -> Address: - """Smart contract funding wallet.""" - return self.light_account.address + def derive_w3(self): + return self.w3s[ChainID.DERIVE] @property def private_key(self) -> str: @@ -159,9 +139,26 @@ def private_key(self) -> str: return self.account._private_key @functools.cached_property - def deposit_helper(self) -> Contract: + def light_account(self): + """Smart contract funding wallet.""" + return _load_light_account(w3=self.derive_w3, wallet=self.wallet) + + async def verify_owner(self): + """We verify the wallet owner on each prepare_deposit and prepare_withdrawal.""" - match self.remote_chain_id: + owner = await self.light_account.functions.owner().call() + if owner != self.owner: + raise BridgePrimarySignerRequiredError( + "Bridging disabled for secondary session-key signers: old-style assets " + "(USDC, USDT) on Derive cannot specify a custom receiver. Using a " + "secondary signer routes funds to the session key's contract instead of " + "the primary owner's. Please run all bridge operations with the " + "primary wallet owner." + ) + + def get_deposit_helper(self, context: BridgeContext) -> Contract: + + match context.source_chain: case ChainID.ARBITRUM: address = self.config.contracts.ARBITRUM_DEPOSIT_WRAPPER case ChainID.OPTIMISM: @@ -170,7 +167,7 @@ def deposit_helper(self) -> Contract: address = self.config.contracts.DEPOSIT_WRAPPER abi = json.loads(DEPOSIT_HELPER_ABI_PATH.read_text()) - return get_contract(w3=self.remote_w3, address=address, abi=abi) + return get_contract(w3=self.w3s[context.source_chain], address=address, abi=abi) @functools.cached_property def withdraw_wrapper(self) -> Contract: @@ -182,72 +179,69 @@ def withdraw_wrapper(self) -> Contract: def _make_bridge_context( self, direction: Direction, - bridge_type: BridgeType, currency: Currency, + remote_chain_id: ChainID, ) -> BridgeContext: is_deposit = direction == Direction.DEPOSIT - src_w3, tgt_w3 = (self.remote_w3, self.derive_w3) if is_deposit else (self.derive_w3, self.remote_w3) - src_chain, tgt_chain = ( - (self.remote_chain_id, ChainID.DERIVE) if is_deposit else (ChainID.DERIVE, self.remote_chain_id) - ) - if bridge_type == BridgeType.LAYERZERO and currency is Currency.DRV: + if is_deposit: + src_w3, tgt_w3 = self.w3s[remote_chain_id], self.derive_w3 + src_chain, tgt_chain = remote_chain_id, ChainID.DERIVE + else: + src_w3, tgt_w3 = self.derive_w3, self.w3s[remote_chain_id] + src_chain, tgt_chain = ChainID.DERIVE, remote_chain_id + + if currency is Currency.DRV: src_addr = DeriveTokenAddresses[src_chain.name].value tgt_addr = DeriveTokenAddresses[tgt_chain.name].value derive_abi = json.loads(DERIVE_L2_ABI_PATH.read_text()) - remote_abi_path = DERIVE_ABI_PATH if self.remote_chain_id == ChainID.ETH else DERIVE_L2_ABI_PATH + remote_abi_path = DERIVE_ABI_PATH if remote_chain_id == ChainID.ETH else DERIVE_L2_ABI_PATH remote_abi = json.loads(remote_abi_path.read_text()) src_abi, tgt_abi = (remote_abi, derive_abi) if is_deposit else (derive_abi, remote_abi) src = get_contract(src_w3, src_addr, abi=src_abi) tgt = get_contract(tgt_w3, tgt_addr, abi=tgt_abi) src_event, tgt_event = src.events.OFTSent(), tgt.events.OFTReceived() - context = BridgeContext(src_w3, tgt_w3, src, src_event, tgt_event, src_chain, tgt_chain) + context = BridgeContext(currency, src_w3, tgt_w3, src, src_event, tgt_event, src_chain, tgt_chain) return context - elif bridge_type == BridgeType.SOCKET and currency is not Currency.DRV: - erc20_abi = json.loads(ERC20_ABI_PATH.read_text()) - socket_abi = json.loads(SOCKET_ABI_PATH.read_text()) - - if is_deposit: - token_data: NonMintableTokenData = self.derive_addresses.chains[self.remote_chain_id][currency] - token_contract = get_contract(src_w3, token_data.NonMintableToken, abi=erc20_abi) - else: - token_data: MintableTokenData = self.derive_addresses.chains[ChainID.DERIVE][currency] - token_contract = get_contract(src_w3, token_data.MintableToken, abi=erc20_abi) - - src_addr = SocketAddress[src_chain.name].value - tgt_addr = SocketAddress[tgt_chain.name].value - src_socket = get_contract(src_w3, address=src_addr, abi=socket_abi) - tgt_socket = get_contract(tgt_w3, address=tgt_addr, abi=socket_abi) - src_event, tgt_event = src_socket.events.MessageOutbound(), tgt_socket.events.ExecutionSuccess() - context = BridgeContext(src_w3, tgt_w3, token_contract, src_event, tgt_event, src_chain, tgt_chain) - return context + erc20_abi = json.loads(ERC20_ABI_PATH.read_text()) + socket_abi = json.loads(SOCKET_ABI_PATH.read_text()) - raise BridgeRouteError(f"Unsupported bridge_type={bridge_type} for currency={currency}.") + if is_deposit: + token_data: NonMintableTokenData = self.derive_addresses.chains[src_chain][currency] + token_contract = get_contract(src_w3, token_data.NonMintableToken, abi=erc20_abi) + else: + token_data: MintableTokenData = self.derive_addresses.chains[src_chain][currency] + token_contract = get_contract(src_w3, token_data.MintableToken, abi=erc20_abi) + + src_addr = SocketAddress[src_chain.name].value + tgt_addr = SocketAddress[tgt_chain.name].value + src_socket = get_contract(src_w3, address=src_addr, abi=socket_abi) + tgt_socket = get_contract(tgt_w3, address=tgt_addr, abi=socket_abi) + src_event, tgt_event = src_socket.events.MessageOutbound(), tgt_socket.events.ExecutionSuccess() + context = BridgeContext(currency, src_w3, tgt_w3, token_contract, src_event, tgt_event, src_chain, tgt_chain) + return context def _get_context(self, state: PreparedBridgeTx | BridgeTxResult) -> BridgeContext: direction = Direction.WITHDRAW if state.source_chain == ChainID.DERIVE else Direction.DEPOSIT + remote_chain_id = state.target_chain if direction == Direction.WITHDRAW else state.source_chain context = self._make_bridge_context( direction=direction, - bridge_type=state.bridge, currency=state.currency, + remote_chain_id=remote_chain_id, ) return context def _resolve_socket_route( self, - direction: Direction, - currency: Currency, + context: BridgeContext, ) -> tuple[MintableTokenData | NonMintableTokenData, Address]: - src_chain, tgt_chain = ( - (self.remote_chain_id, ChainID.DERIVE) - if direction == Direction.DEPOSIT - else (ChainID.DERIVE, self.remote_chain_id) - ) + currency = context.currency + src_chain, tgt_chain = context.source_chain, context.target_chain if (src_token_data := self.derive_addresses.chains[src_chain].get(currency)) is None: msg = f"No bridge path for {currency.name} from {src_chain.name} to {tgt_chain.name}." @@ -269,7 +263,6 @@ async def _prepare_tx( self, func: ContractFunction, value: int, - currency: Currency, context: BridgeContext, ) -> PreparedBridgeTx: @@ -284,10 +277,8 @@ async def _prepare_tx( signed_tx=signed_tx, ) - bridge = BridgeType.LAYERZERO if currency == Currency.DRV else BridgeType.SOCKET prepared_tx = PreparedBridgeTx( - currency=currency, - bridge=bridge, + currency=context.currency, source_chain=context.source_chain, target_chain=context.target_chain, tx_details=tx_details, @@ -295,21 +286,31 @@ async def _prepare_tx( return prepared_tx - async def prepare_deposit(self, amount: int, currency: Currency) -> PreparedBridgeTx: + async def prepare_deposit(self, amount: int, currency: Currency, chain_id: ChainID) -> PreparedBridgeTx: + await self.verify_owner() + + direction = Direction.DEPOSIT if currency == Currency.DRV: - prepared_tx = await self._prepare_layerzero_deposit(amount=amount, currency=currency) + context = self._make_bridge_context(direction, currency=currency, remote_chain_id=chain_id) + prepared_tx = await self._prepare_layerzero_deposit(amount=amount, context=context) else: - prepared_tx = await self._prepare_socket_deposit(amount=amount, currency=currency) + context = self._make_bridge_context(direction, currency=currency, remote_chain_id=chain_id) + prepared_tx = await self._prepare_socket_deposit(amount=amount, context=context) return prepared_tx - async def prepare_withdrawal(self, amount: int, currency: Currency) -> PreparedBridgeTx: + async def prepare_withdrawal(self, amount: int, currency: Currency, chain_id: ChainID) -> PreparedBridgeTx: + await self.verify_owner() + + direction = Direction.WITHDRAW if currency == Currency.DRV: - prepared_tx = await self._prepare_layerzero_withdrawal(amount=amount, currency=currency) + context = self._make_bridge_context(direction, currency=currency, remote_chain_id=chain_id) + prepared_tx = await self._prepare_layerzero_withdrawal(amount=amount, context=context) else: - prepared_tx = await self._prepare_socket_withdrawal(amount=amount, currency=currency) + context = self._make_bridge_context(direction, currency=currency, remote_chain_id=chain_id) + prepared_tx = await self._prepare_socket_withdrawal(amount=amount, context=context) return prepared_tx @@ -330,14 +331,11 @@ async def poll_bridge_progress(self, tx_result: BridgeTxResult) -> BridgeTxResul return tx_result - async def _prepare_socket_deposit(self, amount: int, currency: Currency) -> PreparedBridgeTx: + async def _prepare_socket_deposit(self, amount: int, context: BridgeContext) -> PreparedBridgeTx: - direction = Direction.DEPOSIT - bridge_type = BridgeType.SOCKET - token_data, _connector = self._resolve_socket_route(direction, currency=currency) - context = self._make_bridge_context(direction, bridge_type=bridge_type, currency=currency) + token_data, _connector = self._resolve_socket_route(context=context) - spender = token_data.Vault if token_data.isNewBridge else self.deposit_helper.address + spender = token_data.Vault if token_data.isNewBridge else self.get_deposit_helper(context).address await ensure_token_balance(context.source_token, self.owner, amount) await ensure_token_allowance( w3=context.source_w3, @@ -350,21 +348,18 @@ async def _prepare_socket_deposit(self, amount: int, currency: Currency) -> Prep ) if token_data.isNewBridge: - func, fees_func = self._prepare_new_style_deposit(token_data, amount) + func, fees_func = self._prepare_new_style_deposit(token_data, amount, context) else: - func, fees_func = self._prepare_old_style_deposit(token_data, amount) + func, fees_func = self._prepare_old_style_deposit(token_data, amount, context) fees = await fees_func.call() - prepared_tx = await self._prepare_tx(func=func, value=fees + 1, currency=currency, context=context) + prepared_tx = await self._prepare_tx(func=func, value=fees + 1, context=context) return prepared_tx - async def _prepare_socket_withdrawal(self, amount: int, currency: Currency) -> PreparedBridgeTx: + async def _prepare_socket_withdrawal(self, amount: int, context: BridgeContext) -> PreparedBridgeTx: - direction = Direction.WITHDRAW - bridge_type = BridgeType.SOCKET - token_data, connector = self._resolve_socket_route(direction, currency=currency) - context = self._make_bridge_context(direction, bridge_type=bridge_type, currency=currency) + token_data, connector = self._resolve_socket_route(context=context) # ensure_token_balance(context.source_token, self.wallet, amount) # self._check_bridge_funds(token_data, connector, amount) @@ -387,15 +382,11 @@ async def _prepare_socket_withdrawal(self, amount: int, currency: Currency) -> P dest=[context.source_token.address, self.withdraw_wrapper.address], func=[approve_data, bridge_data], ) - prepared_tx = await self._prepare_tx(func=func, value=0, currency=currency, context=context) + prepared_tx = await self._prepare_tx(func=func, value=0, context=context) return prepared_tx - async def _prepare_layerzero_deposit(self, amount: int, currency: Currency) -> PreparedBridgeTx: - - direction = Direction.DEPOSIT - bridge_type = BridgeType.LAYERZERO - context = self._make_bridge_context(direction, bridge_type=bridge_type, currency=currency) + async def _prepare_layerzero_deposit(self, amount: int, context: BridgeContext) -> PreparedBridgeTx: # check allowance, if needed approve await ensure_token_balance(context.source_token, self.owner, amount) @@ -428,16 +419,12 @@ async def _prepare_layerzero_deposit(self, amount: int, currency: Currency) -> P native_fee, lz_token_fee = fees refund_address = self.owner - func = await context.source_token.functions.send(send_params, fees, refund_address) - prepared_tx = await self._prepare_tx(func=func, value=native_fee, currency=currency, context=context) + func = context.source_token.functions.send(send_params, fees, refund_address) + prepared_tx = await self._prepare_tx(func=func, value=native_fee, context=context) return prepared_tx - async def _prepare_layerzero_withdrawal(self, amount: int, currency: Currency) -> PreparedBridgeTx: - - direction = Direction.WITHDRAW - bridge_type = BridgeType.LAYERZERO - context = self._make_bridge_context(direction, bridge_type=bridge_type, currency=currency) + async def _prepare_layerzero_withdrawal(self, amount: int, context: BridgeContext) -> PreparedBridgeTx: abi = json.loads(LYRA_OFT_WITHDRAW_WRAPPER_ABI_PATH.read_text()) withdraw_wrapper = get_contract(context.source_w3, LYRA_OFT_WITHDRAW_WRAPPER_ADDRESS, abi=abi) @@ -463,7 +450,7 @@ async def _prepare_layerzero_withdrawal(self, amount: int, currency: Currency) - dest=[context.source_token.address, withdraw_wrapper.address], func=[approve_data, bridge_data], ) - prepared_tx = await self._prepare_tx(func=func, value=0, currency=currency, context=context) + prepared_tx = await self._prepare_tx(func=func, value=0, context=context) return prepared_tx @@ -480,7 +467,7 @@ async def send_bridge_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResult: tx_result = BridgeTxResult( currency=prepared_tx.currency, - bridge=prepared_tx.bridge, + bridge=context.bridge_type, source_chain=context.source_chain, target_chain=context.target_chain, source_tx=source_tx, @@ -579,9 +566,11 @@ def matching_message_id(log: AttributeDict) -> bool: return await wait_for_event(context.target_w3, filter_params, condition=matching_message_id, logger=self.logger) - def _prepare_new_style_deposit(self, token_data: NonMintableTokenData, amount: int) -> tuple[ContractFunction, int]: + def _prepare_new_style_deposit( + self, token_data: NonMintableTokenData, amount: int, context: BridgeContext + ) -> tuple[ContractFunction, int]: - vault_contract = _load_vault_contract(w3=self.remote_w3, token_data=token_data) + vault_contract = _load_vault_contract(w3=self.w3s[context.source_chain], token_data=token_data) connector = token_data.connectors[ChainID.DERIVE][TARGET_SPEED] fees_func = _get_min_fees(bridge_contract=vault_contract, connector=connector, token_data=token_data) func = vault_contract.functions.bridge( @@ -595,12 +584,14 @@ def _prepare_new_style_deposit(self, token_data: NonMintableTokenData, amount: i return func, fees_func - def _prepare_old_style_deposit(self, token_data: NonMintableTokenData, amount: int) -> tuple[ContractFunction, int]: + def _prepare_old_style_deposit( + self, token_data: NonMintableTokenData, amount: int, context: BridgeContext + ) -> tuple[ContractFunction, int]: - vault_contract = _load_vault_contract(w3=self.remote_w3, token_data=token_data) + vault_contract = _load_vault_contract(w3=self.w3s[context.source_chain], token_data=token_data) connector = token_data.connectors[ChainID.DERIVE][TARGET_SPEED] fees_func = _get_min_fees(bridge_contract=vault_contract, connector=connector, token_data=token_data) - func = self.deposit_helper.functions.depositToLyra( + func = self.get_deposit_helper(context).functions.depositToLyra( token=token_data.NonMintableToken, socketVault=token_data.Vault, isSCW=True, diff --git a/derive_client/_bridge/w3.py b/derive_client/_bridge/w3.py index 09d23d24..993219c4 100644 --- a/derive_client/_bridge/w3.py +++ b/derive_client/_bridge/w3.py @@ -148,6 +148,10 @@ def get_w3_connection( return w3 +def get_w3_connections(logger) -> dict[ChainID, AsyncWeb3]: + return {chain_id: get_w3_connection(chain_id, logger=logger) for chain_id in ChainID} + + def get_contract(w3: AsyncWeb3, address: str, abi: list) -> AsyncContract: return w3.eth.contract(address=AsyncWeb3.to_checksum_address(address), abi=abi) diff --git a/derive_client/clients/base_client.py b/derive_client/clients/base_client.py index b2a0c1ae..3f91f030 100644 --- a/derive_client/clients/base_client.py +++ b/derive_client/clients/base_client.py @@ -162,14 +162,14 @@ def deposit_to_derive_result( """ amount = int(amount * 10 ** TOKEN_DECIMALS[UnderlyingCurrency[currency.name.upper()]]) + client = BridgeClient(self.env, chain_id, account=self.signer, wallet=self.wallet, logger=self.logger) - async def _run(account, wallet, logger): - client = await BridgeClient.create(self.env, chain_id, account=account, wallet=wallet, logger=logger) - prepared_tx = await client.prepare_deposit(amount=amount, currency=currency) + async def _run(): + prepared_tx = await client.prepare_deposit(amount=amount, currency=currency, chain_id=chain_id) tx_result = await client.submit_bridge_tx(prepared_tx) return await client.poll_bridge_progress(tx_result=tx_result) - return asyncio.run(_run(account=self.signer, wallet=self.wallet, logger=self.logger)) + return asyncio.run(_run()) def deposit_to_derive( self, @@ -216,14 +216,14 @@ def withdraw_from_derive_result( """ amount = int(amount * 10 ** TOKEN_DECIMALS[UnderlyingCurrency[currency.name.upper()]]) + client = BridgeClient(self.env, account=self.signer, wallet=self.wallet, logger=self.logger) - async def _run(account, wallet, logger): - client = await BridgeClient.create(self.env, chain_id, account=account, wallet=wallet, logger=logger) - prepared_tx = await client.prepare_withdrawal(amount=amount, currency=currency) + async def _run(): + prepared_tx = await client.prepare_withdrawal(amount=amount, currency=currency, chain_id=chain_id) tx_result = await client.submit_bridge_tx(prepared_tx) return await client.poll_bridge_progress(tx_result=tx_result) - return asyncio.run(_run(account=self.signer, wallet=self.wallet, logger=self.logger)) + return asyncio.run(_run()) def withdraw_from_derive( self, @@ -262,13 +262,12 @@ def poll_bridge_progress_result(self, tx_result: BridgeTxResult) -> Result[Bridg or an Exception on failure. """ - chain_id = tx_result.source_chain if tx_result.source_chain != ChainID.DERIVE else tx_result.target_chain + client = BridgeClient(account=self.signer, wallet=self.wallet, logger=self.logger) - async def _run(account, wallet, logger): - client = await BridgeClient.create(self.env, chain_id, account=account, wallet=wallet, logger=logger) + async def _run(): return await client.poll_bridge_progress(tx_result=tx_result) - return asyncio.run(_run(account=self.signer, wallet=self.wallet, logger=self.logger)) + return asyncio.run(_run()) def poll_bridge_progress(self, tx_result: BridgeTxResult) -> BridgeTxResult: """ diff --git a/derive_client/data_types/models.py b/derive_client/data_types/models.py index 54c1edbd..c67f234c 100644 --- a/derive_client/data_types/models.py +++ b/derive_client/data_types/models.py @@ -11,12 +11,21 @@ from pydantic import BaseModel, ConfigDict, Field, GetCoreSchemaHandler, GetJsonSchemaHandler, HttpUrl from pydantic.dataclasses import dataclass from pydantic_core import core_schema -from web3 import Web3, AsyncWeb3 +from web3 import AsyncWeb3, Web3 from web3.contract import AsyncContract from web3.contract.async_contract import AsyncContractEvent from web3.datastructures import AttributeDict -from .enums import BridgeType, ChainID, Currency, DeriveTxStatus, MainnetCurrency, MarginType, SessionKeyScope, TxStatus +from .enums import ( + BridgeType, + ChainID, + Currency, + DeriveTxStatus, + MainnetCurrency, + MarginType, + SessionKeyScope, + TxStatus, +) class PAttributeDict(AttributeDict): @@ -206,6 +215,7 @@ class ManagerAddress(BaseModel): @dataclass(config=ConfigDict(arbitrary_types_allowed=True)) class BridgeContext: + currency: Currency source_w3: AsyncWeb3 target_w3: AsyncWeb3 source_token: AsyncContract @@ -214,6 +224,10 @@ class BridgeContext: source_chain: ChainID target_chain: ChainID + @property + def bridge_type(self) -> BridgeType: + return BridgeType.LAYERZERO if self.currency == Currency.DRV else BridgeType.SOCKET + @dataclass class BridgeTxDetails: @@ -237,7 +251,6 @@ def nonce(self) -> str: @dataclass class PreparedBridgeTx: currency: Currency - bridge: BridgeType source_chain: ChainID target_chain: ChainID tx_details: BridgeTxDetails From 0376fd6f29940c68e6397959eb4442548247f8f1 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Fri, 15 Aug 2025 11:24:00 +0200 Subject: [PATCH 054/101] feat: @future_safe on BridgeClient API interface --- derive_client/_bridge/client.py | 44 ++++++++++++++++++++-------- derive_client/clients/base_client.py | 26 +++++++++------- derive_client/data_types/models.py | 11 +------ derive_client/utils/unwrap.py | 10 +++++-- 4 files changed, 56 insertions(+), 35 deletions(-) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index bb519d1d..a232f576 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -9,6 +9,8 @@ from logging import Logger from eth_account import Account +from returns.future import future_safe +from returns.io import IOResult from web3 import Web3 from web3.contract import Contract from web3.contract.contract import ContractFunction @@ -286,7 +288,14 @@ async def _prepare_tx( return prepared_tx - async def prepare_deposit(self, amount: int, currency: Currency, chain_id: ChainID) -> PreparedBridgeTx: + @future_safe + async def prepare_deposit( + self, + amount: int, + currency: Currency, + chain_id: ChainID, + ) -> IOResult[PreparedBridgeTx, Exception]: + await self.verify_owner() direction = Direction.DEPOSIT @@ -300,7 +309,14 @@ async def prepare_deposit(self, amount: int, currency: Currency, chain_id: Chain return prepared_tx - async def prepare_withdrawal(self, amount: int, currency: Currency, chain_id: ChainID) -> PreparedBridgeTx: + @future_safe + async def prepare_withdrawal( + self, + amount: int, + currency: Currency, + chain_id: ChainID, + ) -> IOResult[PreparedBridgeTx, Exception]: + await self.verify_owner() direction = Direction.WITHDRAW @@ -314,20 +330,22 @@ async def prepare_withdrawal(self, amount: int, currency: Currency, chain_id: Ch return prepared_tx - async def submit_bridge_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResult: + @future_safe + async def submit_bridge_tx(self, prepared_tx: PreparedBridgeTx) -> IOResult[BridgeTxResult, Exception]: - tx_result = await self.send_bridge_tx(prepared_tx=prepared_tx) + tx_result = await self._send_bridge_tx(prepared_tx=prepared_tx) return tx_result - async def poll_bridge_progress(self, tx_result: BridgeTxResult) -> BridgeTxResult: + @future_safe + async def poll_bridge_progress(self, tx_result: BridgeTxResult) -> IOResult[BridgeTxResult, Exception]: try: - tx_result.source_tx.tx_receipt = await self.confirm_source_tx(tx_result=tx_result) - tx_result.target_tx = TxResult(tx_hash=await self.wait_for_target_event(tx_result=tx_result)) - tx_result.target_tx.tx_receipt = await self.confirm_target_tx(tx_result=tx_result) + tx_result.source_tx.tx_receipt = await self._confirm_source_tx(tx_result=tx_result) + tx_result.target_tx = TxResult(tx_hash=await self._wait_for_target_event(tx_result=tx_result)) + tx_result.target_tx.tx_receipt = await self._confirm_target_tx(tx_result=tx_result) except Exception as e: - raise PartialBridgeResult("Bridge pipeline failed", tx_result=tx_result) from e + raise PartialBridgeResult(f"Bridge pipeline failed: {e}", tx_result=tx_result) from e return tx_result @@ -454,7 +472,7 @@ async def _prepare_layerzero_withdrawal(self, amount: int, context: BridgeContex return prepared_tx - async def send_bridge_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResult: + async def _send_bridge_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResult: context = self._get_context(prepared_tx) @@ -477,7 +495,7 @@ async def send_bridge_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResult: return tx_result - async def confirm_source_tx(self, tx_result: BridgeTxResult) -> TxReceipt: + async def _confirm_source_tx(self, tx_result: BridgeTxResult) -> TxReceipt: context = self._get_context(tx_result) msg = "⏳ Checking source chain [%s] tx receipt for %s" @@ -490,7 +508,7 @@ async def confirm_source_tx(self, tx_result: BridgeTxResult) -> TxReceipt: return tx_receipt - async def wait_for_target_event(self, tx_result: BridgeTxResult) -> HexBytes: + async def _wait_for_target_event(self, tx_result: BridgeTxResult) -> HexBytes: bridge_event_fetchers = { BridgeType.SOCKET: self._fetch_socket_event_log, @@ -506,7 +524,7 @@ async def wait_for_target_event(self, tx_result: BridgeTxResult) -> HexBytes: return tx_hash - async def confirm_target_tx(self, tx_result: BridgeTxResult) -> TxReceipt: + async def _confirm_target_tx(self, tx_result: BridgeTxResult) -> TxReceipt: context = self._get_context(tx_result) msg = "⏳ Checking target chain [%s] tx receipt for %s" diff --git a/derive_client/clients/base_client.py b/derive_client/clients/base_client.py index 3f91f030..7fd5b467 100644 --- a/derive_client/clients/base_client.py +++ b/derive_client/clients/base_client.py @@ -162,14 +162,17 @@ def deposit_to_derive_result( """ amount = int(amount * 10 ** TOKEN_DECIMALS[UnderlyingCurrency[currency.name.upper()]]) - client = BridgeClient(self.env, chain_id, account=self.signer, wallet=self.wallet, logger=self.logger) + client = BridgeClient(self.env, account=self.signer, wallet=self.wallet, logger=self.logger) async def _run(): - prepared_tx = await client.prepare_deposit(amount=amount, currency=currency, chain_id=chain_id) - tx_result = await client.submit_bridge_tx(prepared_tx) - return await client.poll_bridge_progress(tx_result=tx_result) + future = ( + client.prepare_deposit(amount=amount, currency=currency, chain_id=chain_id) + .bind(client.submit_bridge_tx) + .bind(client.poll_bridge_progress) + ) + return await future.awaitable() - return asyncio.run(_run()) + return unwrap_or_raise(asyncio.run(_run())) def deposit_to_derive( self, @@ -219,11 +222,14 @@ def withdraw_from_derive_result( client = BridgeClient(self.env, account=self.signer, wallet=self.wallet, logger=self.logger) async def _run(): - prepared_tx = await client.prepare_withdrawal(amount=amount, currency=currency, chain_id=chain_id) - tx_result = await client.submit_bridge_tx(prepared_tx) - return await client.poll_bridge_progress(tx_result=tx_result) - - return asyncio.run(_run()) + future = ( + client.prepare_withdrawal(amount=amount, currency=currency, chain_id=chain_id) + .bind(client.submit_bridge_tx) + .bind(client.poll_bridge_progress) + ) + return await future.awaitable() + + return unwrap_or_raise(asyncio.run(_run())) def withdraw_from_derive( self, diff --git a/derive_client/data_types/models.py b/derive_client/data_types/models.py index c67f234c..99c8a5a8 100644 --- a/derive_client/data_types/models.py +++ b/derive_client/data_types/models.py @@ -16,16 +16,7 @@ from web3.contract.async_contract import AsyncContractEvent from web3.datastructures import AttributeDict -from .enums import ( - BridgeType, - ChainID, - Currency, - DeriveTxStatus, - MainnetCurrency, - MarginType, - SessionKeyScope, - TxStatus, -) +from .enums import BridgeType, ChainID, Currency, DeriveTxStatus, MainnetCurrency, MarginType, SessionKeyScope, TxStatus class PAttributeDict(AttributeDict): diff --git a/derive_client/utils/unwrap.py b/derive_client/utils/unwrap.py index 17a93f3f..bb299e94 100644 --- a/derive_client/utils/unwrap.py +++ b/derive_client/utils/unwrap.py @@ -1,11 +1,13 @@ from typing import TypeVar +from returns.io import IOFailure, IOResult, IOSuccess from returns.result import Failure, Result, Success +from returns.unsafe import unsafe_perform_io T = TypeVar("T") -def unwrap_or_raise(result: Result[T, Exception]) -> T: +def unwrap_or_raise(result: Result[T, Exception] | IOResult[T, Exception]) -> T: """Convert a returns.Result into a normal Python value or raise the underlying exception.""" match result: @@ -13,5 +15,9 @@ def unwrap_or_raise(result: Result[T, Exception]) -> T: return result.unwrap() case Failure(): raise result.failure() + case IOSuccess(): + return unsafe_perform_io(result).unwrap() + case IOFailure(): + raise unsafe_perform_io(result).failure() case _: - raise RuntimeError("unwrap_or_raise received a non-Result value") + raise RuntimeError(f"unwrap_or_raise received a non-Result value: {result}") From ef79324bef880c47db2fe70f6fe28c6e72c2b5d9 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Fri, 15 Aug 2025 12:22:51 +0200 Subject: [PATCH 055/101] feat: integrate BridgeClient with AsyncClient --- derive_client/clients/async_client.py | 47 +++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/derive_client/clients/async_client.py b/derive_client/clients/async_client.py index 41312125..86801d41 100644 --- a/derive_client/clients/async_client.py +++ b/derive_client/clients/async_client.py @@ -3,6 +3,7 @@ """ import asyncio +import functools import json import time from datetime import datetime @@ -11,8 +12,21 @@ import aiohttp from derive_action_signing.utils import sign_ws_login, utc_now_ms +from derive_client._bridge import BridgeClient from derive_client.constants import DEFAULT_REFERER, TEST_PRIVATE_KEY -from derive_client.data_types import Environment, InstrumentType, OrderSide, OrderType, TimeInForce, UnderlyingCurrency +from derive_client.data_types import ( + BridgeTxResult, + ChainID, + Currency, + Environment, + InstrumentType, + OrderSide, + OrderType, + PreparedBridgeTx, + TimeInForce, + UnderlyingCurrency, +) +from derive_client.utils import unwrap_or_raise from .base_client import DeriveJSONRPCException from .ws_client import WsClient @@ -49,7 +63,36 @@ def __init__( self.message_queues = {} self.connecting = False - # we make sure to get the event loop + + @functools.cached_property + def bridge(self) -> BridgeClient: + return BridgeClient(env=self.env, account=self.signer, wallet=self.wallet, logger=self.logger) + + async def prepare_deposit_to_derive( + self, + amount: float, + currency: Currency, + chain_id: ChainID, + ) -> PreparedBridgeTx: + result = await self.bridge.prepare_deposit(token_amount=amount, currency=currency, chain_id=chain_id) + return unwrap_or_raise(result) + + async def prepare_withdrawal_from_derive( + self, + amount: float, + currency: Currency, + chain_id: ChainID, + ) -> PreparedBridgeTx: + result = await self.bridge.prepare_withdrawal(token_amount=amount, currency=currency, chain_id=chain_id) + return unwrap_or_raise(result) + + async def submit_bridge_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResult: + result = await self.bridge.submit_bridge_tx(prepared_tx=prepared_tx) + return unwrap_or_raise(result) + + async def poll_bridge_progress(self, tx_result: BridgeTxResult) -> BridgeTxResult: + result = await self.bridge.poll_bridge_progress(tx_result=tx_result) + return unwrap_or_raise(result) def get_subscription_id(self, instrument_name: str, group: str = "1", depth: str = "100"): return f"orderbook.{instrument_name}.{group}.{depth}" From e34945b47acf8a57e663a3b1422ab8fffd4200c6 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Fri, 15 Aug 2025 12:23:16 +0200 Subject: [PATCH 056/101] feat: to_base_units --- derive_client/utils/w3.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/derive_client/utils/w3.py b/derive_client/utils/w3.py index 5d64ffb0..49e16de9 100644 --- a/derive_client/utils/w3.py +++ b/derive_client/utils/w3.py @@ -11,8 +11,8 @@ from web3 import Web3 from web3.providers.rpc import HTTPProvider -from derive_client.constants import DEFAULT_RPC_ENDPOINTS -from derive_client.data_types import ChainID, RPCEndpoints +from derive_client.constants import DEFAULT_RPC_ENDPOINTS, TOKEN_DECIMALS +from derive_client.data_types import ChainID, Currency, RPCEndpoints, UnderlyingCurrency from derive_client.exceptions import NoAvailableRPC from derive_client.utils.logger import get_logger @@ -147,3 +147,9 @@ def get_w3_connection( ) w3.middleware_onion.add(rotator) return w3 + + +def to_base_units(token_amount: float, currency: Currency) -> int: + """Convert a human-readable token amount to base units using the currency's decimals.""" + + return int(token_amount * 10 ** TOKEN_DECIMALS[UnderlyingCurrency[currency.name.upper()]]) From 4b85b4e9c51eb2747b481ba4a81f5298ed50f4db Mon Sep 17 00:00:00 2001 From: zarathustra Date: Fri, 15 Aug 2025 12:24:26 +0200 Subject: [PATCH 057/101] fix: convert token units to base units in BridgeClient --- derive_client/_bridge/client.py | 7 +++++-- derive_client/clients/base_client.py | 6 ++---- derive_client/utils/__init__.py | 3 ++- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index a232f576..10fff0c2 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -63,6 +63,7 @@ PartialBridgeResult, ) from derive_client.utils import get_prod_derive_addresses +from derive_client.utils.w3 import to_base_units from .w3 import ( build_standard_transaction, @@ -291,11 +292,12 @@ async def _prepare_tx( @future_safe async def prepare_deposit( self, - amount: int, + token_amount: float, currency: Currency, chain_id: ChainID, ) -> IOResult[PreparedBridgeTx, Exception]: + amount: int = to_base_units(token_amount=token_amount, currency=currency) await self.verify_owner() direction = Direction.DEPOSIT @@ -312,11 +314,12 @@ async def prepare_deposit( @future_safe async def prepare_withdrawal( self, - amount: int, + token_amount: float, currency: Currency, chain_id: ChainID, ) -> IOResult[PreparedBridgeTx, Exception]: + amount: int = to_base_units(token_amount=token_amount, currency=currency) await self.verify_owner() direction = Direction.WITHDRAW diff --git a/derive_client/clients/base_client.py b/derive_client/clients/base_client.py index 7fd5b467..bee06ec6 100644 --- a/derive_client/clients/base_client.py +++ b/derive_client/clients/base_client.py @@ -161,12 +161,11 @@ def deposit_to_derive_result( or an Exception on failure. """ - amount = int(amount * 10 ** TOKEN_DECIMALS[UnderlyingCurrency[currency.name.upper()]]) client = BridgeClient(self.env, account=self.signer, wallet=self.wallet, logger=self.logger) async def _run(): future = ( - client.prepare_deposit(amount=amount, currency=currency, chain_id=chain_id) + client.prepare_deposit(token_amount=amount, currency=currency, chain_id=chain_id) .bind(client.submit_bridge_tx) .bind(client.poll_bridge_progress) ) @@ -218,12 +217,11 @@ def withdraw_from_derive_result( or an Exception on failure. """ - amount = int(amount * 10 ** TOKEN_DECIMALS[UnderlyingCurrency[currency.name.upper()]]) client = BridgeClient(self.env, account=self.signer, wallet=self.wallet, logger=self.logger) async def _run(): future = ( - client.prepare_withdrawal(amount=amount, currency=currency, chain_id=chain_id) + client.prepare_withdrawal(token_amount=amount, currency=currency, chain_id=chain_id) .bind(client.submit_bridge_tx) .bind(client.poll_bridge_progress) ) diff --git a/derive_client/utils/__init__.py b/derive_client/utils/__init__.py index a2c40e91..c3b72e40 100644 --- a/derive_client/utils/__init__.py +++ b/derive_client/utils/__init__.py @@ -5,7 +5,7 @@ from .prod_addresses import get_prod_derive_addresses from .retry import exp_backoff_retry, get_retry_session, wait_until from .unwrap import unwrap_or_raise -from .w3 import get_w3_connection, load_rpc_endpoints +from .w3 import get_w3_connection, load_rpc_endpoints, to_base_units __all__ = [ "get_logger", @@ -15,6 +15,7 @@ "wait_until", "get_w3_connection", "load_rpc_endpoints", + "to_base_units", "download_prod_address_abis", "unwrap_or_raise", ] From 5f5c3100abc7735bf804aa55cf5afe6092899401 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Fri, 15 Aug 2025 12:57:38 +0200 Subject: [PATCH 058/101] docs: docstrings for bridge methods on AsyncClient --- derive_client/clients/async_client.py | 101 ++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/derive_client/clients/async_client.py b/derive_client/clients/async_client.py index 86801d41..d19e5011 100644 --- a/derive_client/clients/async_client.py +++ b/derive_client/clients/async_client.py @@ -74,6 +74,29 @@ async def prepare_deposit_to_derive( currency: Currency, chain_id: ChainID, ) -> PreparedBridgeTx: + """ + Prepare a deposit transaction to bridge tokens to Derive. + + This creates a signed transaction ready for submission but does not execute it. + Review the returned PreparedBridgeTx before calling submit_bridge_tx(). + + Args: + amount: Amount in token units (e.g., 1.5 USDC, 0.1 ETH) + currency: Token to bridge + chain_id: Source chain to bridge from + + Returns: + PreparedBridgeTx: Contains transaction details including: + - tx_hash: Pre-computed transaction hash + - nonce: Transaction nonce for replacement/cancellation + - tx_details: Contract address, method, gas estimates, signed transaction + - currency, source_chain, target_chain: Bridge context + + Use the returned object to: + - Verify contract addresses and gas costs before submission + - Submit with submit_bridge_tx() on approval + """ + result = await self.bridge.prepare_deposit(token_amount=amount, currency=currency, chain_id=chain_id) return unwrap_or_raise(result) @@ -83,14 +106,92 @@ async def prepare_withdrawal_from_derive( currency: Currency, chain_id: ChainID, ) -> PreparedBridgeTx: + """ + Prepare a withdrawal transaction to bridge tokens from Derive. + + This creates a signed transaction ready for submission but does not execute it. + Review the returned PreparedBridgeTx before calling submit_bridge_tx(). + + Args: + amount: Amount in token units (e.g., 1.5 USDC, 0.1 ETH) + currency: Token to bridge + chain_id: Target chain to bridge to + + Returns: + PreparedBridgeTx: Contains transaction details including: + - tx_hash: Pre-computed transaction hash + - nonce: Transaction nonce for replacement/cancellation + - tx_details: Contract address, method, gas estimates, signed transaction + - currency, source_chain, target_chain: Bridge context + + Use the returned object to: + - Verify contract addresses and gas costs before submission + - Submit with submit_bridge_tx() when ready + """ + result = await self.bridge.prepare_withdrawal(token_amount=amount, currency=currency, chain_id=chain_id) return unwrap_or_raise(result) async def submit_bridge_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResult: + """ + Submit a prepared bridge transaction to the blockchain. + + This broadcasts the signed transaction and returns tracking information. + The transaction is submitted but not yet confirmed - use poll_bridge_progress() + to monitor completion. + + Args: + prepared_tx: Transaction prepared by prepare_deposit_to_derive() + or prepare_withdrawal_from_derive() + + Returns: + BridgeTxResult: Initial tracking object containing: + - source_tx: Transaction hash on source chain (unconfirmed) + - target_from_block: Block number to start polling target chain events + - tx_details: Copy of original transaction details + - currency, bridge, source_chain, target_chain: Bridge context + + Next steps: + - Call poll_bridge_progress() to wait for cross-chain completion + """ + result = await self.bridge.submit_bridge_tx(prepared_tx=prepared_tx) return unwrap_or_raise(result) async def poll_bridge_progress(self, tx_result: BridgeTxResult) -> BridgeTxResult: + """ + Poll for bridge transaction completion across both chains. + + This monitors the full cross-chain bridge pipeline: + 1. Source chain finality + 2. Target chain event detection + 3. Target chain finality + + Args: + tx_result: Result from submit_bridge_tx() or previous poll attempt + + Returns: + BridgeTxResult: Updated with completed bridge information: + - source_tx.tx_receipt: Source chain transaction receipt (confirmed) + - target_tx.tx_hash: Target chain transaction hash + - target_tx.tx_receipt: Target chain transaction receipt (confirmed) + + Raises: + PartialBridgeResult: Pipeline failed at some step. The exception contains + the partially updated tx_result for inspection and retry. Common scenarios: + - FinalityTimeout: Not enough confirmations, wait longer + - TxPendingTimeout: Transaction stuck, consider resubmission + - TransactionDropped: Transaction lost, likely needs resubmission + + Recovery strategies: + - On PartialBridgeResult: inspect the tx_result in the exception + - For FinalityTimeout: call poll_bridge_progress() again with the partial result + - For TransactionDropped: prepare new tx with same nonce to replace + - For TxPendingTimeout: prepare new tx with higher gas using same nonce. + - In case of a nonce collision: verify whether previous transaction got included + or whether the nonce was reused in another tx. + """ + result = await self.bridge.poll_bridge_progress(tx_result=tx_result) return unwrap_or_raise(result) From 70368396d63660528ed4c2b77617ae08bd1d1687 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Fri, 15 Aug 2025 13:09:29 +0200 Subject: [PATCH 059/101] fix: restore wait_until async -> sync --- derive_client/utils/retry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/derive_client/utils/retry.py b/derive_client/utils/retry.py index 837bbb68..06498b0a 100644 --- a/derive_client/utils/retry.py +++ b/derive_client/utils/retry.py @@ -90,7 +90,7 @@ def log_response(r, *args, **kwargs): return session -async def wait_until( +def wait_until( func: Callable[P, T], condition: Callable[[T], bool], timeout: float = 60.0, @@ -116,7 +116,7 @@ async def wait_until( if time.time() - start_time > timeout: msg = f"Timed out after {timeout}s waiting for condition on {func.__name__} {timeout_message}" raise TimeoutError(msg) - await asyncio.sleep(poll_interval) + time.sleep(poll_interval) def is_retryable(e: RequestException) -> bool: From a1f33de04a06d0e99ba13e0073fbe2b1b83a0b64 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Fri, 15 Aug 2025 13:09:55 +0200 Subject: [PATCH 060/101] fix: imports in test_w3 --- tests/test_w3.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_w3.py b/tests/test_w3.py index a807fb5e..4cf62053 100644 --- a/tests/test_w3.py +++ b/tests/test_w3.py @@ -11,7 +11,9 @@ from derive_client.constants import DEFAULT_RPC_ENDPOINTS from derive_client.data_types import ChainID, EthereumJSONRPCErrorCode -from derive_client.utils import get_logger, load_rpc_endpoints, make_rotating_provider_middleware +from derive_client.utils import get_logger, load_rpc_endpoints +from derive_client.utils.w3 import make_rotating_provider_middleware + RPC_ENDPOINTS = list(load_rpc_endpoints(DEFAULT_RPC_ENDPOINTS).model_dump().items()) From ebe31e14dd2246442c032c548cdbed72b5179724 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Sat, 16 Aug 2025 18:13:45 +0200 Subject: [PATCH 061/101] feat: fee estimation enum and models --- derive_client/data_types/enums.py | 6 ++++ derive_client/data_types/models.py | 57 ++++++++++++++++++++++++++++-- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/derive_client/data_types/enums.py b/derive_client/data_types/enums.py index e8cbefe4..c30ea0f4 100644 --- a/derive_client/data_types/enums.py +++ b/derive_client/data_types/enums.py @@ -57,6 +57,12 @@ class LayerZeroChainIDv2(IntEnum): DERIVE = 30311 +class GasPriority(IntEnum): + SLOW = 25 + MEDIUM = 50 + FAST = 75 + + class SocketAddress(Enum): ETH = "0x943ac2775928318653e91d350574436a1b9b16f9" ARBITRUM = "0x37cc674582049b579571e2ffd890a4d99355f6ba" diff --git a/derive_client/data_types/models.py b/derive_client/data_types/models.py index 99c8a5a8..7318a69a 100644 --- a/derive_client/data_types/models.py +++ b/derive_client/data_types/models.py @@ -8,7 +8,7 @@ from eth_account.datastructures import SignedTransaction from eth_utils import is_0x_prefixed, is_address, is_hex, to_checksum_address from hexbytes import HexBytes -from pydantic import BaseModel, ConfigDict, Field, GetCoreSchemaHandler, GetJsonSchemaHandler, HttpUrl +from pydantic import BaseModel, RootModel, ConfigDict, Field, GetCoreSchemaHandler, GetJsonSchemaHandler, HttpUrl from pydantic.dataclasses import dataclass from pydantic_core import core_schema from web3 import AsyncWeb3, Web3 @@ -16,7 +16,17 @@ from web3.contract.async_contract import AsyncContractEvent from web3.datastructures import AttributeDict -from .enums import BridgeType, ChainID, Currency, DeriveTxStatus, MainnetCurrency, MarginType, SessionKeyScope, TxStatus +from .enums import ( + BridgeType, + ChainID, + Currency, + DeriveTxStatus, + MainnetCurrency, + MarginType, + SessionKeyScope, + TxStatus, + GasPriority, +) class PAttributeDict(AttributeDict): @@ -136,6 +146,24 @@ def _validate(cls, v: str | HexBytes) -> str: return v +class Wei(int): + @classmethod + def __get_pydantic_core_schema__(cls, _source, _handler: GetCoreSchemaHandler) -> core_schema.CoreSchema: + return core_schema.no_info_before_validator_function(cls._validate, core_schema.int_schema()) + + @classmethod + def __get_pydantic_json_schema__(cls, _schema, _handler: GetJsonSchemaHandler) -> dict: + return {"type": ["string", "integer"], "title": "Wei"} + + @classmethod + def _validate(cls, v: str | int) -> int: + if isinstance(v, int): + return v + if isinstance(v, str) and is_hex(v): + return int(v, 16) + raise TypeError(f"Invalid type for Wei: {type(v)}") + + @dataclass class CreateSubAccountDetails: amount: int @@ -320,3 +348,28 @@ def __getitem__(self, key: ChainID | int | str) -> list[HttpUrl]: if not (urls := getattr(self, chain.name, [])): raise ValueError(f"No RPC URLs configured for {chain.name}") return urls + + +class FeeHistory(BaseModel): + base_fee_per_gas: list[Wei] = Field(alias="baseFeePerGas") + gas_used_ratio: list[float] = Field(alias="gasUsedRatio") + base_fee_per_blob_gas: list[Wei] = Field(alias="baseFeePerBlobGas") + blob_gas_used_ratio: list[float] = Field(alias="blobGasUsedRatio") + oldest_block: int = Field(alias="oldestBlock") + reward: list[list[Wei]] + + +@dataclass +class FeeEstimate: + max_fee_per_gas: int + max_priority_fee_per_gas: int + + +class FeeEstimates(RootModel): + root: dict[GasPriority, FeeEstimate] + + def __getitem__(self, key: GasPriority): + return self.root[key] + + def items(self): + return self.root.items() From 22a71940c30f7cfcfaf7a7882edbdc4c7764bd47 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Sat, 16 Aug 2025 18:19:13 +0200 Subject: [PATCH 062/101] fix: gas fee estimation --- derive_client/_bridge/client.py | 3 +- derive_client/_bridge/w3.py | 84 ++++++++++++++++------------ derive_client/data_types/__init__.py | 8 +++ 3 files changed, 58 insertions(+), 37 deletions(-) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index 10fff0c2..1776b141 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -269,7 +269,8 @@ async def _prepare_tx( context: BridgeContext, ) -> PreparedBridgeTx: - tx = await build_standard_transaction(func=func, account=self.account, w3=context.source_w3, value=value) + w3 = context.source_w3 + tx = await build_standard_transaction(func=func, account=self.account, w3=w3, value=value, logger=self.logger) signed_tx = sign_tx(w3=context.source_w3, tx=tx, private_key=self.private_key) tx_details = BridgeTxDetails( diff --git a/derive_client/_bridge/w3.py b/derive_client/_bridge/w3.py index 993219c4..73399484 100644 --- a/derive_client/_bridge/w3.py +++ b/derive_client/_bridge/w3.py @@ -4,6 +4,7 @@ import time from logging import Logger from typing import Any, Callable, Generator, Literal +import statistics from eth_account import Account from eth_account.datastructures import SignedTransaction @@ -14,7 +15,16 @@ from web3.datastructures import AttributeDict from derive_client.constants import ABI_DATA_DIR, DEFAULT_RPC_ENDPOINTS, GAS_FEE_BUFFER, GAS_LIMIT_BUFFER -from derive_client.data_types import Address, ChainID, RPCEndpoints, TxStatus +from derive_client.data_types import ( + Address, + ChainID, + RPCEndpoints, + TxStatus, + FeeHistory, + FeeEstimate, + FeeEstimates, + GasPriority, +) from derive_client.exceptions import ( FinalityTimeout, InsufficientNativeBalance, @@ -203,7 +213,7 @@ async def _increase_token_allowance( logger: Logger, ) -> None: func = erc20_contract.functions.approve(spender, amount) - tx = await build_standard_transaction(func=func, account=from_account, w3=w3) + tx = await build_standard_transaction(func=func, account=from_account, w3=w3, logger=logger) signed_tx = sign_tx(w3=w3, tx=tx, private_key=private_key) tx_hash = await send_tx(w3=w3, signed_tx=signed_tx) tx_receipt = await wait_for_tx_finality(w3=w3, tx_hash=tx_hash, logger=logger) @@ -211,6 +221,30 @@ async def _increase_token_allowance( raise RuntimeError("approve() failed") +async def estimate_fees(w3, blocks: int = 20) -> FeeEstimates: + """Estimate EIP-1559 maxFeePerGas and maxPriorityFeePerGas from recent blocks for GasPriority percentiles.""" + + percentiles = tuple(map(int, GasPriority)) + fee_history = FeeHistory(**await w3.eth.fee_history(blocks, "pending", percentiles)) + latest_base_fee = fee_history.base_fee_per_gas[-1] + + percentile_rewards = {p: [] for p in percentiles} + for block_rewards in fee_history.reward: + for percentile, reward in zip(percentiles, block_rewards): + percentile_rewards[percentile].append(reward) + + estimates = {} + for percentile in percentiles: + rewards = percentile_rewards[percentile] + estimated_priority_fee = int(statistics.median(rewards)) + + buffered_base_fee = int(latest_base_fee * GAS_FEE_BUFFER) + estimated_max_fee = buffered_base_fee + estimated_priority_fee + estimates[percentile] = FeeEstimate(estimated_max_fee, estimated_priority_fee) + + return FeeEstimates(estimates) + + async def simulate_tx(w3: AsyncWeb3, tx: dict, account: Account) -> dict: balance = await w3.eth.get_balance(account.address) max_fee_per_gas = tx["maxFeePerGas"] @@ -232,23 +266,28 @@ async def build_standard_transaction( func, account: Account, w3: AsyncWeb3, + logger: Logger, value: int = 0, - gas_blocks: int = 100, - gas_percentile: int = 99, + gas_blocks: int = 30, + gas_priority: GasPriority = GasPriority.MEDIUM, ) -> dict: """Standardized transaction building with EIP-1559 and gas estimation""" nonce = await w3.eth.get_transaction_count(account.address) - fee_estimations = await estimate_fees(w3, blocks=gas_blocks, percentiles=[gas_percentile]) - max_fee = fee_estimations[0]["maxFeePerGas"] - priority_fee = fee_estimations[0]["maxPriorityFeePerGas"] + fee_estimations = await estimate_fees(w3, blocks=gas_blocks) + + for percentile, fee_estimation in fee_estimations.items(): + logger.debug(f"{fee_estimation} [{percentile}% Percentile]") + + fee_estimate = fee_estimations[gas_priority] + logger.info(f"Fee estimate: {fee_estimate} [Gas priority {gas_priority.name} | {gas_priority.value}% Percentile]") tx = await func.build_transaction( { "from": account.address, "nonce": nonce, - "maxFeePerGas": max_fee, - "maxPriorityFeePerGas": priority_fee, + "maxFeePerGas": fee_estimate.max_fee_per_gas, + "maxPriorityFeePerGas": fee_estimate.max_priority_fee_per_gas, "chainId": await w3.eth.chain_id, "value": value, } @@ -363,33 +402,6 @@ async def send_tx(w3: AsyncWeb3, signed_tx: SignedTransaction) -> str: return tx_hash.to_0x_hex() -async def estimate_fees(w3, percentiles: list[int], blocks=20, default_tip=10_000): - fee_history = await w3.eth.fee_history(blocks, "pending", percentiles) - base_fees = fee_history["baseFeePerGas"] - rewards = fee_history["reward"] - - # Calculate average priority fees for each percentile - avg_priority_fees = [] - for i in range(len(percentiles)): - nonzero_rewards = [r[i] for r in rewards if len(r) > i and r[i] > 0] - if nonzero_rewards: - estimated_tip = sum(nonzero_rewards) // len(nonzero_rewards) - else: - estimated_tip = default_tip - avg_priority_fees.append(estimated_tip) - - # Use the latest base fee - latest_base_fee = base_fees[-1] - - # Calculate max fees - fee_estimations = [] - for priority_fee in avg_priority_fees: - max_fee = int((latest_base_fee + priority_fee) * GAS_FEE_BUFFER) - fee_estimations.append({"maxFeePerGas": max_fee, "maxPriorityFeePerGas": priority_fee}) - - return fee_estimations - - async def iter_events( w3: AsyncWeb3, filter_params: dict, diff --git a/derive_client/data_types/__init__.py b/derive_client/data_types/__init__.py index a658e727..807709d6 100644 --- a/derive_client/data_types/__init__.py +++ b/derive_client/data_types/__init__.py @@ -25,6 +25,7 @@ TimeInForce, TxStatus, UnderlyingCurrency, + GasPriority, ) from .models import ( Address, @@ -44,6 +45,9 @@ SessionKey, TxResult, WithdrawResult, + FeeHistory, + FeeEstimate, + FeeEstimates, ) __all__ = [ @@ -70,6 +74,10 @@ "SubaccountType", "CollateralAsset", "ActionType", + "GasPriority", + "FeeHistory", + "FeeEstimate", + "FeeEstimates", "RfqStatus", "Address", "SessionKey", From 8ed741def8fd571a0ca67ef6f79602b9869d1bc3 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Sat, 16 Aug 2025 19:59:19 +0200 Subject: [PATCH 063/101] feat: preflight_native_balance_check --- derive_client/_bridge/w3.py | 47 +++++++++++++++++----------- derive_client/constants.py | 2 +- derive_client/data_types/__init__.py | 10 +++--- derive_client/data_types/models.py | 4 +-- tests/test_w3.py | 1 - 5 files changed, 38 insertions(+), 26 deletions(-) diff --git a/derive_client/_bridge/w3.py b/derive_client/_bridge/w3.py index 73399484..dd47c8de 100644 --- a/derive_client/_bridge/w3.py +++ b/derive_client/_bridge/w3.py @@ -1,10 +1,10 @@ import asyncio import heapq import json +import statistics import time from logging import Logger from typing import Any, Callable, Generator, Literal -import statistics from eth_account import Account from eth_account.datastructures import SignedTransaction @@ -14,16 +14,17 @@ from web3.contract.async_contract import AsyncContract, AsyncContractEvent from web3.datastructures import AttributeDict -from derive_client.constants import ABI_DATA_DIR, DEFAULT_RPC_ENDPOINTS, GAS_FEE_BUFFER, GAS_LIMIT_BUFFER +from derive_client.constants import ABI_DATA_DIR, ASSUMED_BRIDGE_GAS_LIMIT, DEFAULT_RPC_ENDPOINTS, GAS_FEE_BUFFER from derive_client.data_types import ( Address, ChainID, - RPCEndpoints, - TxStatus, - FeeHistory, FeeEstimate, FeeEstimates, + FeeHistory, GasPriority, + RPCEndpoints, + TxStatus, + Wei, ) from derive_client.exceptions import ( FinalityTimeout, @@ -87,8 +88,9 @@ async def rotating_backoff(method: str, params: Any) -> Any: async with lock: heapq.heappush(heap, state) err_msg = error.get("message", "") - msg = "RPC error on %s: %s → backing off %.2fs" - logger.info(msg, state.provider.endpoint_uri, err_msg, state.backoff, extra=resp) + err_code = error.get("code", "") + msg = "RPC error on %s: %s (code: %s)→ backing off %.2fs" + logger.info(msg, state.provider.endpoint_uri, err_msg, err_code, state.backoff) continue # 4) on success, reset its backoff and re-schedule immediately @@ -245,20 +247,21 @@ async def estimate_fees(w3, blocks: int = 20) -> FeeEstimates: return FeeEstimates(estimates) -async def simulate_tx(w3: AsyncWeb3, tx: dict, account: Account) -> dict: +async def preflight_native_balance_check( + w3: AsyncWeb3, fee_estimate: FeeEstimate, account: Account, value: Wei +) -> None: balance = await w3.eth.get_balance(account.address) - max_fee_per_gas = tx["maxFeePerGas"] - gas_limit = tx["gas"] - value = tx.get("value", 0) + max_fee_per_gas = fee_estimate.max_fee_per_gas - max_gas_cost = gas_limit * max_fee_per_gas + max_gas_cost = ASSUMED_BRIDGE_GAS_LIMIT * max_fee_per_gas total_cost = max_gas_cost + value + if not balance >= total_cost: ratio = balance / total_cost * 100 - raise InsufficientNativeBalance(f"available: {balance} < required: {total_cost} ({ratio:.2f}%)") - - await w3.eth.call(tx) - return tx + raise InsufficientNativeBalance( + f"Insufficient funds: balance={balance}, required={total_cost} {ratio:.2f}% available " + f"(includes value={value} and assumed gas limit={ASSUMED_BRIDGE_GAS_LIMIT} at {max_fee_per_gas} wei/gas)" + ) @exp_backoff_retry @@ -282,6 +285,8 @@ async def build_standard_transaction( fee_estimate = fee_estimations[gas_priority] logger.info(f"Fee estimate: {fee_estimate} [Gas priority {gas_priority.name} | {gas_priority.value}% Percentile]") + await preflight_native_balance_check(w3=w3, account=account, fee_estimate=fee_estimate, value=value) + tx = await func.build_transaction( { "from": account.address, @@ -293,8 +298,14 @@ async def build_standard_transaction( } ) - tx["gas"] = int(await w3.eth.estimate_gas(tx) * GAS_LIMIT_BUFFER) - return await simulate_tx(w3, tx, account) + # Warn if actual gas exceeds ASSUMED_BRIDGE_GAS_LIMIT; may indicate the limit is too low + # and could cause unhandled RPC errors instead of raising InsufficientNativeBalance + if tx["gas"] > ASSUMED_BRIDGE_GAS_LIMIT: + logger.warning(f"Bridge tx gas {tx['gas']} exceeds assumed limit {ASSUMED_BRIDGE_GAS_LIMIT}") + + # simulate the tx + await w3.eth.call(tx) + return tx async def wait_for_tx_finality( diff --git a/derive_client/constants.py b/derive_client/constants.py index 041589c3..9ec9f9cf 100644 --- a/derive_client/constants.py +++ b/derive_client/constants.py @@ -115,7 +115,7 @@ class EnvConfig(BaseModel, frozen=True): GAS_FEE_BUFFER = 1.1 # buffer multiplier to pad maxFeePerGas GAS_LIMIT_BUFFER = 1.1 # buffer multiplier to pad gas limit MSG_GAS_LIMIT = 200_000 -DEPOSIT_GAS_LIMIT = 420_000 +ASSUMED_BRIDGE_GAS_LIMIT = 1_000_000 PAYLOAD_SIZE = 161 TARGET_SPEED = "FAST" diff --git a/derive_client/data_types/__init__.py b/derive_client/data_types/__init__.py index 807709d6..c4ba0f22 100644 --- a/derive_client/data_types/__init__.py +++ b/derive_client/data_types/__init__.py @@ -12,6 +12,7 @@ Direction, Environment, EthereumJSONRPCErrorCode, + GasPriority, InstrumentType, LayerZeroChainIDv2, MainnetCurrency, @@ -25,7 +26,6 @@ TimeInForce, TxStatus, UnderlyingCurrency, - GasPriority, ) from .models import ( Address, @@ -37,6 +37,9 @@ DepositResult, DeriveAddresses, DeriveTxResult, + FeeEstimate, + FeeEstimates, + FeeHistory, ManagerAddress, MintableTokenData, NonMintableTokenData, @@ -44,10 +47,8 @@ RPCEndpoints, SessionKey, TxResult, + Wei, WithdrawResult, - FeeHistory, - FeeEstimate, - FeeEstimates, ) __all__ = [ @@ -96,4 +97,5 @@ "RPCEndpoints", "BridgeTxDetails", "PreparedBridgeTx", + "Wei", ] diff --git a/derive_client/data_types/models.py b/derive_client/data_types/models.py index 7318a69a..78d22647 100644 --- a/derive_client/data_types/models.py +++ b/derive_client/data_types/models.py @@ -8,7 +8,7 @@ from eth_account.datastructures import SignedTransaction from eth_utils import is_0x_prefixed, is_address, is_hex, to_checksum_address from hexbytes import HexBytes -from pydantic import BaseModel, RootModel, ConfigDict, Field, GetCoreSchemaHandler, GetJsonSchemaHandler, HttpUrl +from pydantic import BaseModel, ConfigDict, Field, GetCoreSchemaHandler, GetJsonSchemaHandler, HttpUrl, RootModel from pydantic.dataclasses import dataclass from pydantic_core import core_schema from web3 import AsyncWeb3, Web3 @@ -21,11 +21,11 @@ ChainID, Currency, DeriveTxStatus, + GasPriority, MainnetCurrency, MarginType, SessionKeyScope, TxStatus, - GasPriority, ) diff --git a/tests/test_w3.py b/tests/test_w3.py index 4cf62053..25dffa2c 100644 --- a/tests/test_w3.py +++ b/tests/test_w3.py @@ -14,7 +14,6 @@ from derive_client.utils import get_logger, load_rpc_endpoints from derive_client.utils.w3 import make_rotating_provider_middleware - RPC_ENDPOINTS = list(load_rpc_endpoints(DEFAULT_RPC_ENDPOINTS).model_dump().items()) REQUIRED_METHODS = { From 13eeb56b513e24b6f8f7c16b19c5160d7c8bffe7 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Mon, 18 Aug 2025 19:43:21 +0200 Subject: [PATCH 064/101] feat: encode_abi helper --- derive_client/_bridge/w3.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/derive_client/_bridge/w3.py b/derive_client/_bridge/w3.py index dd47c8de..d8ea5ba1 100644 --- a/derive_client/_bridge/w3.py +++ b/derive_client/_bridge/w3.py @@ -6,12 +6,13 @@ from logging import Logger from typing import Any, Callable, Generator, Literal +from eth_abi import encode from eth_account import Account from eth_account.datastructures import SignedTransaction from requests import RequestException from web3 import AsyncHTTPProvider, AsyncWeb3 from web3.contract import Contract -from web3.contract.async_contract import AsyncContract, AsyncContractEvent +from web3.contract.async_contract import AsyncContract, AsyncContractEvent, AsyncContractFunction from web3.datastructures import AttributeDict from derive_client.constants import ABI_DATA_DIR, ASSUMED_BRIDGE_GAS_LIMIT, DEFAULT_RPC_ENDPOINTS, GAS_FEE_BUFFER @@ -257,10 +258,15 @@ async def preflight_native_balance_check( total_cost = max_gas_cost + value if not balance >= total_cost: + chain_id = ChainID(await w3.eth.chain_id) ratio = balance / total_cost * 100 raise InsufficientNativeBalance( - f"Insufficient funds: balance={balance}, required={total_cost} {ratio:.2f}% available " - f"(includes value={value} and assumed gas limit={ASSUMED_BRIDGE_GAS_LIMIT} at {max_fee_per_gas} wei/gas)" + f"Insufficient funds on {chain_id}: balance={balance}, required={total_cost} {ratio:.2f}% available " + f"(includes value={value} and assumed gas limit={ASSUMED_BRIDGE_GAS_LIMIT} at {max_fee_per_gas} wei/gas)", + balance=balance, + chain_id=chain_id, + assumed_gas_limit=ASSUMED_BRIDGE_GAS_LIMIT, + fee_estimate=fee_estimate, ) @@ -501,3 +507,12 @@ def make_filter_params( raise ValueError(f"Unexpected address filter: {address!r}") return filter_params + + +def encode_abi(func: AsyncContractFunction) -> bytes: + """Get the ABI-encoded data (including 4-byte selector).""" + + types = [arg["internalType"] for arg in func.abi["inputs"]] + selector = bytes.fromhex(func.selector.removeprefix("0x")) + + return selector + encode(types, func.arguments) From fa6ff97f0dceb5a4ead797ad93cc50b3539bed0e Mon Sep 17 00:00:00 2001 From: zarathustra Date: Mon, 18 Aug 2025 19:49:47 +0200 Subject: [PATCH 065/101] feat: Standard bridge contract ABIs --- derive_client/constants.py | 27 +- .../data/abi/l1_cross_domain_messenger.json | 407 ++++++++++++ .../data/abi/l2_cross_domain_messenger.json | 407 ++++++++++++ .../data/abi/l2_standard_bridge.json | 617 ++++++++++++++++++ 4 files changed, 1445 insertions(+), 13 deletions(-) create mode 100644 derive_client/data/abi/l1_cross_domain_messenger.json create mode 100644 derive_client/data/abi/l2_cross_domain_messenger.json create mode 100644 derive_client/data/abi/l2_standard_bridge.json diff --git a/derive_client/constants.py b/derive_client/constants.py index 9ec9f9cf..a0882873 100644 --- a/derive_client/constants.py +++ b/derive_client/constants.py @@ -23,11 +23,6 @@ class ContractAddresses(BaseModel, frozen=True): DEPOSIT_MODULE: str WITHDRAWAL_MODULE: str TRANSFER_MODULE: str - L1_CHUG_SPLASH_PROXY: str | None - WITHDRAW_WRAPPER_V2: str | None - DEPOSIT_WRAPPER: str | None - ARBITRUM_DEPOSIT_WRAPPER: str | None = None - OPTIMISM_DEPOSIT_WRAPPER: str | None = None def __getitem__(self, key): return getattr(self, key) @@ -74,9 +69,6 @@ class EnvConfig(BaseModel, frozen=True): DEPOSIT_MODULE="0x43223Db33AdA0575D2E100829543f8B04A37a1ec", WITHDRAWAL_MODULE="0xe850641C5207dc5E9423fB15f89ae6031A05fd92", TRANSFER_MODULE="0x0CFC1a4a90741aB242cAfaCD798b409E12e68926", - L1_CHUG_SPLASH_PROXY=None, - WITHDRAW_WRAPPER_V2=None, - DEPOSIT_WRAPPER=None, ), ), Environment.PROD: EnvConfig( @@ -100,11 +92,6 @@ class EnvConfig(BaseModel, frozen=True): DEPOSIT_MODULE="0x9B3FE5E5a3bcEa5df4E08c41Ce89C4e3Ff01Ace3", WITHDRAWAL_MODULE="0x9d0E8f5b25384C7310CB8C6aE32C8fbeb645d083", TRANSFER_MODULE="0x01259207A40925b794C8ac320456F7F6c8FE2636", - L1_CHUG_SPLASH_PROXY="0x61e44dc0dae6888b5a301887732217d5725b0bff", - WITHDRAW_WRAPPER_V2="0xea8E683D8C46ff05B871822a00461995F93df800", - DEPOSIT_WRAPPER="0x9628bba16db41ea7fe1fd84f9ce53bc27c63f59b", - ARBITRUM_DEPOSIT_WRAPPER="0x076BB6117750e80AD570D98891B68da86C203A88", # unknown address - OPTIMISM_DEPOSIT_WRAPPER="0xC65005131Cfdf06622b99E8E17f72Cf694b586cC", # unknown address ), ), } @@ -148,11 +135,25 @@ class EnvConfig(BaseModel, frozen=True): LIGHT_ACCOUNT_ABI_PATH = ABI_DATA_DIR / "light_account.json" L1_CHUG_SPLASH_PROXY_ABI_PATH = ABI_DATA_DIR / "l1_chug_splash_proxy.json" L1_STANDARD_BRIDGE_ABI_PATH = ABI_DATA_DIR / "l1_standard_bridge.json" +L1_CROSS_DOMAIN_MESSENGER_ABI_PATH = ABI_DATA_DIR / "l1_cross_domain_messenger.json" +L2_STANDARD_BRIDGE_ABI_PATH = ABI_DATA_DIR / "l2_standard_bridge.json" +L2_CROSS_DOMAIN_MESSENGER_ABI_PATH = ABI_DATA_DIR / "l2_cross_domain_messenger.json" WITHDRAW_WRAPPER_V2_ABI_PATH = ABI_DATA_DIR / "withdraw_wrapper_v2.json" DERIVE_ABI_PATH = ABI_DATA_DIR / "Derive.json" DERIVE_L2_ABI_PATH = ABI_DATA_DIR / "DeriveL2.json" LYRA_OFT_WITHDRAW_WRAPPER_ABI_PATH = ABI_DATA_DIR / "LyraOFTWithdrawWrapper.json" ERC20_ABI_PATH = ABI_DATA_DIR / "erc20.json" SOCKET_ABI_PATH = ABI_DATA_DIR / "Socket.json" +CONNECTOR_PLUG = ABI_DATA_DIR / "ConnectorPlug.json" + +# Contracts used in bridging module LYRA_OFT_WITHDRAW_WRAPPER_ADDRESS = "0x9400cc156dad38a716047a67c897973A29A06710" +L1_CHUG_SPLASH_PROXY = "0x61e44dc0dae6888b5a301887732217d5725b0bff" +RESOLVED_DELEGATE_PROXY = "0x5456f02c08e9A018E42C39b351328E5AA864174A" +L2_STANDARD_BRIDGE_PROXY = "0x4200000000000000000000000000000000000010" +L2_CROSS_DOMAIN_MESSENGER_PROXY = "0x4200000000000000000000000000000000000007" +WITHDRAW_WRAPPER_V2 = "0xea8E683D8C46ff05B871822a00461995F93df800" +BASE_DEPOSIT_WRAPPER = "0x9628bba16db41ea7fe1fd84f9ce53bc27c63f59b" +ARBITRUM_DEPOSIT_WRAPPER = "0x076BB6117750e80AD570D98891B68da86C203A88" +OPTIMISM_DEPOSIT_WRAPPER = "0xC65005131Cfdf06622b99E8E17f72Cf694b586cC" diff --git a/derive_client/data/abi/l1_cross_domain_messenger.json b/derive_client/data/abi/l1_cross_domain_messenger.json new file mode 100644 index 00000000..2ad94c8d --- /dev/null +++ b/derive_client/data/abi/l1_cross_domain_messenger.json @@ -0,0 +1,407 @@ +[ + { + "inputs": [ + { + "internalType": "contract OptimismPortal", + "name": "_portal", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "msgHash", + "type": "bytes32" + } + ], + "name": "FailedRelayedMessage", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint8", + "name": "version", + "type": "uint8" + } + ], + "name": "Initialized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "msgHash", + "type": "bytes32" + } + ], + "name": "RelayedMessage", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "target", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "message", + "type": "bytes" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "messageNonce", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "gasLimit", + "type": "uint256" + } + ], + "name": "SentMessage", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "SentMessageExtension1", + "type": "event" + }, + { + "inputs": [], + "name": "MESSAGE_VERSION", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MIN_GAS_CALLDATA_OVERHEAD", + "outputs": [ + { + "internalType": "uint64", + "name": "", + "type": "uint64" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MIN_GAS_DYNAMIC_OVERHEAD_DENOMINATOR", + "outputs": [ + { + "internalType": "uint64", + "name": "", + "type": "uint64" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MIN_GAS_DYNAMIC_OVERHEAD_NUMERATOR", + "outputs": [ + { + "internalType": "uint64", + "name": "", + "type": "uint64" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "OTHER_MESSENGER", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "PORTAL", + "outputs": [ + { + "internalType": "contract OptimismPortal", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "RELAY_CALL_OVERHEAD", + "outputs": [ + { + "internalType": "uint64", + "name": "", + "type": "uint64" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "RELAY_CONSTANT_OVERHEAD", + "outputs": [ + { + "internalType": "uint64", + "name": "", + "type": "uint64" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "RELAY_GAS_CHECK_BUFFER", + "outputs": [ + { + "internalType": "uint64", + "name": "", + "type": "uint64" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "RELAY_RESERVED_GAS", + "outputs": [ + { + "internalType": "uint64", + "name": "", + "type": "uint64" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes", + "name": "_message", + "type": "bytes" + }, + { + "internalType": "uint32", + "name": "_minGasLimit", + "type": "uint32" + } + ], + "name": "baseGas", + "outputs": [ + { + "internalType": "uint64", + "name": "", + "type": "uint64" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "name": "failedMessages", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "messageNonce", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_nonce", + "type": "uint256" + }, + { + "internalType": "address", + "name": "_sender", + "type": "address" + }, + { + "internalType": "address", + "name": "_target", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_value", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_minGasLimit", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "_message", + "type": "bytes" + } + ], + "name": "relayMessage", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_target", + "type": "address" + }, + { + "internalType": "bytes", + "name": "_message", + "type": "bytes" + }, + { + "internalType": "uint32", + "name": "_minGasLimit", + "type": "uint32" + } + ], + "name": "sendMessage", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "name": "successfulMessages", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "version", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "xDomainMessageSender", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + } +] \ No newline at end of file diff --git a/derive_client/data/abi/l2_cross_domain_messenger.json b/derive_client/data/abi/l2_cross_domain_messenger.json new file mode 100644 index 00000000..086cb416 --- /dev/null +++ b/derive_client/data/abi/l2_cross_domain_messenger.json @@ -0,0 +1,407 @@ +[ + { + "inputs": [ + { + "internalType": "address", + "name": "_l1CrossDomainMessenger", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "msgHash", + "type": "bytes32" + } + ], + "name": "FailedRelayedMessage", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint8", + "name": "version", + "type": "uint8" + } + ], + "name": "Initialized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "msgHash", + "type": "bytes32" + } + ], + "name": "RelayedMessage", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "target", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "message", + "type": "bytes" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "messageNonce", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "gasLimit", + "type": "uint256" + } + ], + "name": "SentMessage", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "SentMessageExtension1", + "type": "event" + }, + { + "inputs": [], + "name": "MESSAGE_VERSION", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MIN_GAS_CALLDATA_OVERHEAD", + "outputs": [ + { + "internalType": "uint64", + "name": "", + "type": "uint64" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MIN_GAS_DYNAMIC_OVERHEAD_DENOMINATOR", + "outputs": [ + { + "internalType": "uint64", + "name": "", + "type": "uint64" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MIN_GAS_DYNAMIC_OVERHEAD_NUMERATOR", + "outputs": [ + { + "internalType": "uint64", + "name": "", + "type": "uint64" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "OTHER_MESSENGER", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "RELAY_CALL_OVERHEAD", + "outputs": [ + { + "internalType": "uint64", + "name": "", + "type": "uint64" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "RELAY_CONSTANT_OVERHEAD", + "outputs": [ + { + "internalType": "uint64", + "name": "", + "type": "uint64" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "RELAY_GAS_CHECK_BUFFER", + "outputs": [ + { + "internalType": "uint64", + "name": "", + "type": "uint64" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "RELAY_RESERVED_GAS", + "outputs": [ + { + "internalType": "uint64", + "name": "", + "type": "uint64" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes", + "name": "_message", + "type": "bytes" + }, + { + "internalType": "uint32", + "name": "_minGasLimit", + "type": "uint32" + } + ], + "name": "baseGas", + "outputs": [ + { + "internalType": "uint64", + "name": "", + "type": "uint64" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "name": "failedMessages", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "l1CrossDomainMessenger", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "messageNonce", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_nonce", + "type": "uint256" + }, + { + "internalType": "address", + "name": "_sender", + "type": "address" + }, + { + "internalType": "address", + "name": "_target", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_value", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_minGasLimit", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "_message", + "type": "bytes" + } + ], + "name": "relayMessage", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_target", + "type": "address" + }, + { + "internalType": "bytes", + "name": "_message", + "type": "bytes" + }, + { + "internalType": "uint32", + "name": "_minGasLimit", + "type": "uint32" + } + ], + "name": "sendMessage", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "name": "successfulMessages", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "version", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "xDomainMessageSender", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + } +] \ No newline at end of file diff --git a/derive_client/data/abi/l2_standard_bridge.json b/derive_client/data/abi/l2_standard_bridge.json new file mode 100644 index 00000000..6dcb8c12 --- /dev/null +++ b/derive_client/data/abi/l2_standard_bridge.json @@ -0,0 +1,617 @@ +[ + { + "inputs": [ + { + "internalType": "addresspayable", + "name": "_otherBridge", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "l1Token", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "l2Token", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "extraData", + "type": "bytes" + } + ], + "name": "DepositFinalized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "localToken", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "remoteToken", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "extraData", + "type": "bytes" + } + ], + "name": "ERC20BridgeFinalized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "localToken", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "remoteToken", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "extraData", + "type": "bytes" + } + ], + "name": "ERC20BridgeInitiated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "extraData", + "type": "bytes" + } + ], + "name": "ETHBridgeFinalized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "extraData", + "type": "bytes" + } + ], + "name": "ETHBridgeInitiated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "l1Token", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "l2Token", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "extraData", + "type": "bytes" + } + ], + "name": "WithdrawalInitiated", + "type": "event" + }, + { + "inputs": [], + "name": "MESSENGER", + "outputs": [ + { + "internalType": "contractCrossDomainMessenger", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "OTHER_BRIDGE", + "outputs": [ + { + "internalType": "contractStandardBridge", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_localToken", + "type": "address" + }, + { + "internalType": "address", + "name": "_remoteToken", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_amount", + "type": "uint256" + }, + { + "internalType": "uint32", + "name": "_minGasLimit", + "type": "uint32" + }, + { + "internalType": "bytes", + "name": "_extraData", + "type": "bytes" + } + ], + "name": "bridgeERC20", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_localToken", + "type": "address" + }, + { + "internalType": "address", + "name": "_remoteToken", + "type": "address" + }, + { + "internalType": "address", + "name": "_to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_amount", + "type": "uint256" + }, + { + "internalType": "uint32", + "name": "_minGasLimit", + "type": "uint32" + }, + { + "internalType": "bytes", + "name": "_extraData", + "type": "bytes" + } + ], + "name": "bridgeERC20To", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint32", + "name": "_minGasLimit", + "type": "uint32" + }, + { + "internalType": "bytes", + "name": "_extraData", + "type": "bytes" + } + ], + "name": "bridgeETH", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_to", + "type": "address" + }, + { + "internalType": "uint32", + "name": "_minGasLimit", + "type": "uint32" + }, + { + "internalType": "bytes", + "name": "_extraData", + "type": "bytes" + } + ], + "name": "bridgeETHTo", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "deposits", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_localToken", + "type": "address" + }, + { + "internalType": "address", + "name": "_remoteToken", + "type": "address" + }, + { + "internalType": "address", + "name": "_from", + "type": "address" + }, + { + "internalType": "address", + "name": "_to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_amount", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "_extraData", + "type": "bytes" + } + ], + "name": "finalizeBridgeERC20", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_from", + "type": "address" + }, + { + "internalType": "address", + "name": "_to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_amount", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "_extraData", + "type": "bytes" + } + ], + "name": "finalizeBridgeETH", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_l1Token", + "type": "address" + }, + { + "internalType": "address", + "name": "_l2Token", + "type": "address" + }, + { + "internalType": "address", + "name": "_from", + "type": "address" + }, + { + "internalType": "address", + "name": "_to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_amount", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "_extraData", + "type": "bytes" + } + ], + "name": "finalizeDeposit", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [], + "name": "l1TokenBridge", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "messenger", + "outputs": [ + { + "internalType": "contractCrossDomainMessenger", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "version", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_l2Token", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_amount", + "type": "uint256" + }, + { + "internalType": "uint32", + "name": "_minGasLimit", + "type": "uint32" + }, + { + "internalType": "bytes", + "name": "_extraData", + "type": "bytes" + } + ], + "name": "withdraw", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_l2Token", + "type": "address" + }, + { + "internalType": "address", + "name": "_to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_amount", + "type": "uint256" + }, + { + "internalType": "uint32", + "name": "_minGasLimit", + "type": "uint32" + }, + { + "internalType": "bytes", + "name": "_extraData", + "type": "bytes" + } + ], + "name": "withdrawTo", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "stateMutability": "payable", + "type": "receive" + } +] \ No newline at end of file From a95514ee11262e5c7c9d483aae3b2b58fce95a6d Mon Sep 17 00:00:00 2001 From: zarathustra Date: Mon, 18 Aug 2025 19:51:37 +0200 Subject: [PATCH 066/101] chore: add BridgeType.STANDARD and Currency.ETH --- derive_client/data_types/enums.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/derive_client/data_types/enums.py b/derive_client/data_types/enums.py index c30ea0f4..508e2330 100644 --- a/derive_client/data_types/enums.py +++ b/derive_client/data_types/enums.py @@ -23,6 +23,7 @@ class DeriveTxStatus(Enum): class BridgeType(Enum): SOCKET = "socket" LAYERZERO = "layerzero" + STANDARD = "standard" class Direction(Enum): @@ -129,6 +130,8 @@ class UnderlyingCurrency(Enum): class Currency(Enum): """Depositable currencies""" + ETH = "ETH" + weETH = "weETH" rswETH = "rswETH" rsETH = "rsETH" From 4d914343c6e8d1003a7a1a4fdbb65cfefb76ff72 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Mon, 18 Aug 2025 19:52:36 +0200 Subject: [PATCH 067/101] feat: add data to InsufficientNativeBalance exception --- derive_client/exceptions.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/derive_client/exceptions.py b/derive_client/exceptions.py index 5dae0ae0..ca7a3083 100644 --- a/derive_client/exceptions.py +++ b/derive_client/exceptions.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from derive_client.data_types import BridgeTxResult + from derive_client.data_types import BridgeTxResult, ChainID, Wei, FeeEstimate class ApiException(Exception): @@ -61,6 +61,13 @@ class NoAvailableRPC(Exception): class InsufficientNativeBalance(Exception): """Raised when the native currency balance is insufficient for gas and/or value transfer.""" + def __init__(self, message: str, *, chain_id: ChainID, balance: Wei, assumed_gas_limit: Wei, fee_estimate: FeeEstimate): + super().__init__(message) + self.chain_id = chain_id + self.balance = balance + self.assumed_gas_limit = assumed_gas_limit + self.fee_estimate = fee_estimate + class InsufficientTokenBalance(Exception): """Raised when the token balance is insufficient for the requested operation.""" @@ -97,7 +104,7 @@ class TransactionDropped(Exception): class PartialBridgeResult(Exception): """Raised after submission when the bridge pipeline fails""" - def __init__(self, message: str, *, tx_result: "BridgeTxResult"): + def __init__(self, message: str, *, tx_result: BridgeTxResult): super().__init__(message) self.tx_result = tx_result From 497c8866f8559caf44187221fb8e69611bacdf0f Mon Sep 17 00:00:00 2001 From: zarathustra Date: Mon, 18 Aug 2025 19:53:17 +0200 Subject: [PATCH 068/101] feat: add amount field to PreparedBridgeTx and BridgeTxResult --- derive_client/data_types/models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/derive_client/data_types/models.py b/derive_client/data_types/models.py index 78d22647..b4c29ae6 100644 --- a/derive_client/data_types/models.py +++ b/derive_client/data_types/models.py @@ -269,6 +269,7 @@ def nonce(self) -> str: @dataclass class PreparedBridgeTx: + amount: int currency: Currency source_chain: ChainID target_chain: ChainID @@ -299,6 +300,7 @@ def status(self) -> TxStatus: @dataclass(config=ConfigDict(validate_assignment=True)) class BridgeTxResult: + amount: int currency: Currency bridge: BridgeType source_chain: ChainID From ca199eafca0920460f31f704369acd5dbd6fbb11 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Tue, 19 Aug 2025 11:43:18 +0200 Subject: [PATCH 069/101] chore: expose bridge_type on PreparedBridgeTx --- derive_client/data_types/models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/derive_client/data_types/models.py b/derive_client/data_types/models.py index b4c29ae6..7fd86cb4 100644 --- a/derive_client/data_types/models.py +++ b/derive_client/data_types/models.py @@ -273,6 +273,7 @@ class PreparedBridgeTx: currency: Currency source_chain: ChainID target_chain: ChainID + bridge_type: BridgeType tx_details: BridgeTxDetails @property @@ -302,7 +303,7 @@ def status(self) -> TxStatus: class BridgeTxResult: amount: int currency: Currency - bridge: BridgeType + bridge_type: BridgeType source_chain: ChainID target_chain: ChainID source_tx: TxResult From 4c274296aee293ace4696c086609e7f417d254ed Mon Sep 17 00:00:00 2001 From: zarathustra Date: Tue, 19 Aug 2025 12:00:16 +0200 Subject: [PATCH 070/101] fix: update BridgeClient --- derive_client/_bridge/client.py | 59 +++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index 1776b141..f17b5db0 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -18,6 +18,8 @@ from web3.types import HexBytes, LogReceipt, TxReceipt from derive_client.constants import ( + ARBITRUM_DEPOSIT_WRAPPER, + BASE_DEPOSIT_WRAPPER, CONFIGS, CONTROLLER_ABI_PATH, CONTROLLER_V0_ABI_PATH, @@ -32,9 +34,11 @@ MSG_GAS_LIMIT, NEW_VAULT_ABI_PATH, OLD_VAULT_ABI_PATH, + OPTIMISM_DEPOSIT_WRAPPER, PAYLOAD_SIZE, SOCKET_ABI_PATH, TARGET_SPEED, + WITHDRAW_WRAPPER_V2, WITHDRAW_WRAPPER_V2_ABI_PATH, ) from derive_client.data_types import ( @@ -159,22 +163,24 @@ async def verify_owner(self): "primary wallet owner." ) - def get_deposit_helper(self, context: BridgeContext) -> Contract: + def get_deposit_helper(self, chain_id: ChainID) -> Contract: - match context.source_chain: + match chain_id: case ChainID.ARBITRUM: - address = self.config.contracts.ARBITRUM_DEPOSIT_WRAPPER + address = ARBITRUM_DEPOSIT_WRAPPER case ChainID.OPTIMISM: - address = self.config.contracts.OPTIMISM_DEPOSIT_WRAPPER + address = OPTIMISM_DEPOSIT_WRAPPER + case ChainID.BASE: + address = BASE_DEPOSIT_WRAPPER case _: - address = self.config.contracts.DEPOSIT_WRAPPER + raise ValueError(f"Deposit helper not supported on : {chain_id.name}") abi = json.loads(DEPOSIT_HELPER_ABI_PATH.read_text()) - return get_contract(w3=self.w3s[context.source_chain], address=address, abi=abi) + return get_contract(w3=self.w3s[chain_id], address=address, abi=abi) @functools.cached_property def withdraw_wrapper(self) -> Contract: - address = self.config.contracts.WITHDRAW_WRAPPER_V2 + address = WITHDRAW_WRAPPER_V2 abi = json.loads(WITHDRAW_WRAPPER_V2_ABI_PATH.read_text()) return get_contract(w3=self.derive_w3, address=address, abi=abi) @@ -282,9 +288,11 @@ async def _prepare_tx( ) prepared_tx = PreparedBridgeTx( + amount=value, currency=context.currency, source_chain=context.source_chain, target_chain=context.target_chain, + bridge_type=context.bridge_type, tx_details=tx_details, ) @@ -298,6 +306,9 @@ async def prepare_deposit( chain_id: ChainID, ) -> IOResult[PreparedBridgeTx, Exception]: + if currency is Currency.ETH: + raise NotImplementedError("ETH deposits are not implemented.") + amount: int = to_base_units(token_amount=token_amount, currency=currency) await self.verify_owner() @@ -320,6 +331,9 @@ async def prepare_withdrawal( chain_id: ChainID, ) -> IOResult[PreparedBridgeTx, Exception]: + if currency is Currency.ETH: + raise NotImplementedError("ETH withdrawals are not implemented.") + amount: int = to_base_units(token_amount=token_amount, currency=currency) await self.verify_owner() @@ -357,7 +371,7 @@ async def _prepare_socket_deposit(self, amount: int, context: BridgeContext) -> token_data, _connector = self._resolve_socket_route(context=context) - spender = token_data.Vault if token_data.isNewBridge else self.get_deposit_helper(context).address + spender = token_data.Vault if token_data.isNewBridge else self.get_deposit_helper(context.source_chain).address await ensure_token_balance(context.source_token, self.owner, amount) await ensure_token_allowance( w3=context.source_w3, @@ -383,8 +397,8 @@ async def _prepare_socket_withdrawal(self, amount: int, context: BridgeContext) token_data, connector = self._resolve_socket_route(context=context) - # ensure_token_balance(context.source_token, self.wallet, amount) - # self._check_bridge_funds(token_data, connector, amount) + await ensure_token_balance(context.source_token, self.wallet, amount) + await self._check_bridge_funds(token_data, connector, amount) kwargs = { "token": context.source_token.address, @@ -488,10 +502,11 @@ async def _send_bridge_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResult source_tx = TxResult(tx_hash=tx_hash) tx_result = BridgeTxResult( + amount=prepared_tx.amount, currency=prepared_tx.currency, - bridge=context.bridge_type, - source_chain=context.source_chain, - target_chain=context.target_chain, + bridge_type=prepared_tx.bridge_type, + source_chain=prepared_tx.source_chain, + target_chain=prepared_tx.target_chain, source_tx=source_tx, target_from_block=target_from_block, tx_details=prepared_tx.tx_details, @@ -518,8 +533,8 @@ async def _wait_for_target_event(self, tx_result: BridgeTxResult) -> HexBytes: BridgeType.SOCKET: self._fetch_socket_event_log, BridgeType.LAYERZERO: self._fetch_lz_event_log, } - if (fetch_event := bridge_event_fetchers.get(tx_result.bridge)) is None: - raise BridgeRouteError(f"Invalid bridge_type: {tx_result.bridge}") + if (fetch_event := bridge_event_fetchers.get(tx_result.bridge_type)) is None: + raise BridgeRouteError(f"Invalid bridge_type: {tx_result.bridge_type}") context = self._get_context(tx_result) event_log = await fetch_event(tx_result, context) @@ -613,7 +628,7 @@ def _prepare_old_style_deposit( vault_contract = _load_vault_contract(w3=self.w3s[context.source_chain], token_data=token_data) connector = token_data.connectors[ChainID.DERIVE][TARGET_SPEED] fees_func = _get_min_fees(bridge_contract=vault_contract, connector=connector, token_data=token_data) - func = self.get_deposit_helper(context).functions.depositToLyra( + func = self.get_deposit_helper(context.source_chain).functions.depositToLyra( token=token_data.NonMintableToken, socketVault=token_data.Vault, isSCW=True, @@ -624,21 +639,21 @@ def _prepare_old_style_deposit( return func, fees_func - def _check_bridge_funds(self, token_data, connector: Address, amount: int) -> None: + async def _check_bridge_funds(self, token_data, connector: Address, amount: int) -> None: controller = _load_controller_contract(w3=self.derive_w3, token_data=token_data) if token_data.isNewBridge: - deposit_hook = controller.functions.hook__().call() + deposit_hook = await controller.functions.hook__().call() expected_hook = token_data.LyraTSAShareHandlerDepositHook if not deposit_hook == token_data.LyraTSAShareHandlerDepositHook: msg = f"Controller deposit hook {deposit_hook} does not match expected address {expected_hook}" raise ValueError(msg) deposit_contract = _load_deposit_contract(w3=self.derive_w3, token_data=token_data) - pool_id = deposit_contract.functions.connectorPoolIds(connector).call() - locked = deposit_contract.functions.poolLockedAmounts(pool_id).call() + pool_id = await deposit_contract.functions.connectorPoolIds(connector).call() + locked = await deposit_contract.functions.poolLockedAmounts(pool_id).call() else: - pool_id = controller.functions.connectorPoolIds(connector).call() - locked = controller.functions.poolLockedAmounts(pool_id).call() + pool_id = await controller.functions.connectorPoolIds(connector).call() + locked = await controller.functions.poolLockedAmounts(pool_id).call() if amount > locked: raise RuntimeError( From 889b238cfee96300e97f096e1d4188db3acb017d Mon Sep 17 00:00:00 2001 From: zarathustra Date: Tue, 19 Aug 2025 12:00:39 +0200 Subject: [PATCH 071/101] feat: StandardBridge --- derive_client/_bridge/standard_bridge.py | 302 +++++++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 derive_client/_bridge/standard_bridge.py diff --git a/derive_client/_bridge/standard_bridge.py b/derive_client/_bridge/standard_bridge.py new file mode 100644 index 00000000..44ffb1fc --- /dev/null +++ b/derive_client/_bridge/standard_bridge.py @@ -0,0 +1,302 @@ + +import json +import asyncio +from logging import Logger + +from eth_account import Account +from web3 import AsyncWeb3 +from web3.contract import AsyncContract +from web3.types import HexBytes, LogReceipt, TxReceipt +from returns.future import future_safe +from returns.io import IOResult +from eth_utils import keccak + +from derive_client.data_types import ( + Address, + ChainID, + BridgeTxResult, + Currency, + BridgeTxDetails, + PreparedBridgeTx, + BridgeType, + TxResult, +) +from derive_client.exceptions import PartialBridgeResult +from derive_client.utils.w3 import to_base_units +from .w3 import ( + encode_abi, + build_standard_transaction, + get_contract, + get_w3_connections, + make_filter_params, + send_tx, + sign_tx, + wait_for_event, + wait_for_tx_finality, +) +from derive_client.constants import ( + L1_STANDARD_BRIDGE_ABI_PATH, + L2_STANDARD_BRIDGE_ABI_PATH, + L1_CROSS_DOMAIN_MESSENGER_ABI_PATH, + L1_CHUG_SPLASH_PROXY, + L2_STANDARD_BRIDGE_PROXY, + L2_CROSS_DOMAIN_MESSENGER_ABI_PATH, + L2_CROSS_DOMAIN_MESSENGER_PROXY, + RESOLVED_DELEGATE_PROXY, + MSG_GAS_LIMIT, +) +from derive_client.exceptions import BridgeEventParseError + + +def _load_l1_contract(w3: AsyncWeb3) -> AsyncContract: + address = L1_CHUG_SPLASH_PROXY + abi = json.loads(L1_STANDARD_BRIDGE_ABI_PATH.read_text()) + return get_contract(w3=w3, address=address, abi=abi) + + +def _load_l2_contract(w3: AsyncWeb3) -> AsyncContract: + address = L2_STANDARD_BRIDGE_PROXY + abi = json.loads(L2_STANDARD_BRIDGE_ABI_PATH.read_text()) + return get_contract(w3=w3, address=address, abi=abi) + + +def _load_l2_contracts(w3s: dict[ChainID, AsyncWeb3]) -> dict[ChainID, AsyncContract]: + return {chain_id: _load_l2_contract(w3) for chain_id, w3 in w3s.items() if chain_id is not ChainID.ETH} + + +def _load_l1_cross_domain_messenger_proxy(w3: AsyncWeb3) -> AsyncContract: + address = RESOLVED_DELEGATE_PROXY + abi = json.loads(L1_CROSS_DOMAIN_MESSENGER_ABI_PATH.read_text()) + return get_contract(w3=w3, address=address, abi=abi) + + +def _load_l2_cross_domain_messenger_proxy(w3: AsyncWeb3) -> AsyncContract: + address = L2_CROSS_DOMAIN_MESSENGER_PROXY + abi = json.loads(L2_CROSS_DOMAIN_MESSENGER_ABI_PATH.read_text()) + return get_contract(w3=w3, address=address, abi=abi) + + +class StandardBridge: + + def __init__(self, account: Account, logger: Logger): + + self.account = account + self.logger = logger + self.w3s = get_w3_connections(logger=logger) + self.l1_contract = _load_l1_contract(self.w3s[ChainID.ETH]) + self.l2_contracts = _load_l2_contracts(self.w3s) + self.l1_messenger_proxy = _load_l1_cross_domain_messenger_proxy(self.w3s[ChainID.ETH]) + self.l2_messenger_proxy = _load_l2_cross_domain_messenger_proxy(self.w3s[ChainID.DERIVE]) + + @future_safe + async def prepare_tx( + self, + token_amount: float, + currency: Currency, + to: Address, + source_chain: ChainID, + target_chain: ChainID, + ) -> IOResult[PreparedBridgeTx, Exception]: + + if currency is not Currency.ETH or source_chain is not ChainID.ETH or target_chain is not ChainID.DERIVE or to != self.account.address: + raise NotImplementedError("Only ETH transfers from Ethereum to Derive EOA are currently supported.") + + amount: int = to_base_units(token_amount=token_amount, currency=currency) + prepared_tx = await self._prepare_tx(amount=amount, currency=currency, to=to, source_chain=source_chain, target_chain=target_chain) + + return prepared_tx + + @property + def private_key(self) -> str: + """Private key of the owner (EOA).""" + return self.account._private_key + + @future_safe + async def submit_bridge_tx(self, prepared_tx: PreparedBridgeTx) -> IOResult[BridgeTxResult, Exception]: + + tx_result = await self._send_bridge_tx(prepared_tx=prepared_tx) + + return tx_result + + @future_safe + async def poll_bridge_progress(self, tx_result: BridgeTxResult) -> IOResult[BridgeTxResult, Exception]: + + try: + tx_result.source_tx.tx_receipt = await self._confirm_source_tx(tx_result=tx_result) + tx_result.target_tx = TxResult(tx_hash=await self._wait_for_target_event(tx_result=tx_result)) + tx_result.target_tx.tx_receipt = await self._confirm_target_tx(tx_result=tx_result) + except Exception as e: + raise PartialBridgeResult(f"Bridge pipeline failed: {e}", tx_result=tx_result) from e + + return tx_result + + async def _prepare_tx( + self, + amount: int, + currency: Currency, + to: Address, + source_chain: ChainID, + target_chain: ChainID, + ) -> PreparedBridgeTx: + + w3 = self.w3s[source_chain] + + proxy_contract = self.l1_contract + func = proxy_contract.functions.bridgeETHTo( + _to=to, + _minGasLimit=MSG_GAS_LIMIT, + _extraData=b"", + ) + value = amount + + tx = await build_standard_transaction(func=func, account=self.account, w3=w3, value=value, logger=self.logger) + + tx_gas_cost = tx["gas"] * tx["maxFeePerGas"] + if value < tx_gas_cost: + msg = f"⚠️ Bridge tx value {value} is smaller than gas cost {tx_gas_cost} (~{tx_gas_cost/value:.2f}x value)." + self.logger.warning(msg) + + signed_tx = sign_tx(w3=w3, tx=tx, private_key=self.private_key) + + tx_details = BridgeTxDetails( + contract=func.address, + method=func.fn_name, + kwargs=func.kwargs, + tx=tx, + signed_tx=signed_tx, + ) + + prepared_tx = PreparedBridgeTx( + amount=amount, + currency=Currency.ETH, + source_chain=source_chain, + target_chain=target_chain, + bridge_type=BridgeType.STANDARD, + tx_details=tx_details, + ) + + return prepared_tx + + async def _send_bridge_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResult: + + source_w3 = self.w3s[prepared_tx.source_chain] + target_w3 = self.w3s[prepared_tx.target_chain] + + # record on target chain where we should start polling + target_from_block = await target_w3.eth.block_number + + signed_tx = prepared_tx.tx_details.signed_tx + tx_hash = await send_tx(w3=source_w3, signed_tx=signed_tx) + source_tx = TxResult(tx_hash=tx_hash) + + tx_result = BridgeTxResult( + amount=prepared_tx.amount, + currency=prepared_tx.currency, + bridge_type=prepared_tx.bridge_type, + source_chain=prepared_tx.source_chain, + target_chain=prepared_tx.target_chain, + source_tx=source_tx, + target_from_block=target_from_block, + tx_details=prepared_tx.tx_details, + ) + + return tx_result + + async def _confirm_source_tx(self, tx_result: BridgeTxResult) -> TxReceipt: + + msg = "⏳ Checking source chain [%s] tx receipt for %s" + self.logger.info(msg, tx_result.source_chain.name, tx_result.source_tx.tx_hash) + + w3 = self.w3s[tx_result.source_chain] + tx_receipt = await wait_for_tx_finality( + w3=w3, + tx_hash=tx_result.source_tx.tx_hash, + logger=self.logger, + ) + + return tx_receipt + + async def _wait_for_target_event(self, tx_result: BridgeTxResult) -> HexBytes: + + event_log = await self._fetch_standard_event_log(tx_result) + tx_hash = event_log["transactionHash"] + self.logger.info(f"Target event tx_hash found: {tx_hash.to_0x_hex()}") + + return tx_hash + + async def _confirm_target_tx(self, tx_result: BridgeTxResult) -> TxReceipt: + + msg = "⏳ Checking target chain [%s] tx receipt for %s" + self.logger.info(msg, tx_result.target_chain.name, tx_result.target_tx.tx_hash) + + w3 = self.w3s[tx_result.target_chain] + tx_receipt = await wait_for_tx_finality( + w3=w3, + tx_hash=tx_result.target_tx.tx_hash, + logger=self.logger, + ) + + return tx_receipt + + async def _fetch_standard_event_log(self, tx_result: BridgeTxResult) -> LogReceipt: + + source_event = self.l1_messenger_proxy.events.SentMessage() + + target_w3 = self.w3s[tx_result.target_chain] + try: + source_event_log = source_event.process_log(tx_result.source_tx.tx_receipt.logs[3]) + nonce = source_event_log["args"]["messageNonce"] + except Exception as e: + raise BridgeEventParseError(f"Could not decode StandardBridge messageNonce: {e}") from e + + self.logger.info(f"🔖 Source [{tx_result.source_chain.name}] messageNonce: {nonce}") + + args = source_event_log["args"] + gas_limit = args["gasLimit"] + sender = AsyncWeb3.to_checksum_address(args["sender"]) + target = AsyncWeb3.to_checksum_address(args["target"]) + message = args["message"] + value = tx_result.amount + + func = self.l1_messenger_proxy.functions.relayMessage( + _nonce=nonce, + _sender=sender, + _target=target, + _value=value, + _minGasLimit=gas_limit, + _message=message, + ) + + msg_hash = keccak(encode_abi(func)) + tx_result.event_id = msg_hash.hex() + self.logger.info(f"🗝️ Computed msgHash: {tx_result.event_id}") + + target_event = self.l2_messenger_proxy.events.RelayedMessage() + failed_target_event = self.l2_messenger_proxy.events.FailedRelayedMessage() + + filter_params = make_filter_params( + event=target_event, + from_block=tx_result.target_from_block, + argument_filters={"msgHash": msg_hash}, + ) + failed_filter_params = make_filter_params( + event=failed_target_event, + from_block=tx_result.target_from_block, + argument_filters={"msgHash": msg_hash}, + ) + + self.logger.info( + f"🔍 Listening for msgHash on [{tx_result.target_chain.name}] at {target_event.address}" + ) + + relayed_task = asyncio.create_task(wait_for_event(target_w3, filter_params, logger=self.logger)) + failed_task = asyncio.create_task(wait_for_event(target_w3, failed_filter_params, logger=self.logger)) + done, pending = await asyncio.wait([relayed_task, failed_task], return_when=asyncio.FIRST_COMPLETED) + for task in pending: + task.cancel() + if failed_task in done: + raise + + event_log = done.pop().result() + + return event_log From 0d40e144f3e360ca855551e8280f9fe4e5962705 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Tue, 19 Aug 2025 12:01:36 +0200 Subject: [PATCH 072/101] feat: expose prepare_standard_tx on AsyncClient --- derive_client/clients/async_client.py | 51 +++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/derive_client/clients/async_client.py b/derive_client/clients/async_client.py index d19e5011..76154f5e 100644 --- a/derive_client/clients/async_client.py +++ b/derive_client/clients/async_client.py @@ -13,8 +13,11 @@ from derive_action_signing.utils import sign_ws_login, utc_now_ms from derive_client._bridge import BridgeClient +from derive_client._bridge.standard_bridge import StandardBridge + from derive_client.constants import DEFAULT_REFERER, TEST_PRIVATE_KEY from derive_client.data_types import ( + Address, BridgeTxResult, ChainID, Currency, @@ -68,9 +71,31 @@ def __init__( def bridge(self) -> BridgeClient: return BridgeClient(env=self.env, account=self.signer, wallet=self.wallet, logger=self.logger) + @functools.cached_property + def standard_bridge(self) -> StandardBridge: + return StandardBridge(self.account, self.logger) + + async def prepare_standard_tx( + self, + token_amount: float, + currency: Currency, + to: Address, + source_chain: ChainID, + target_chain: ChainID, + ) -> PreparedBridgeTx: + + result = self.standard_bridge.prepare_tx( + token_amount=token_amount, + currency=currency, + to=to, + source_chain=source_chain, + target_chain=target_chain, + ) + return unwrap_or_raise(result) + async def prepare_deposit_to_derive( self, - amount: float, + token_amount: float, currency: Currency, chain_id: ChainID, ) -> PreparedBridgeTx: @@ -97,12 +122,18 @@ async def prepare_deposit_to_derive( - Submit with submit_bridge_tx() on approval """ - result = await self.bridge.prepare_deposit(token_amount=amount, currency=currency, chain_id=chain_id) + if currency is Currency.ETH: + raise NotImplementedError( + "ETH deposits to the funding wallet (Light Account) are not implemented. " + "For gas funding of the owner (EOA) use `prepare_standard_tx`." + ) + + result = await self.bridge.prepare_deposit(token_amount=token_amount, currency=currency, chain_id=chain_id) return unwrap_or_raise(result) async def prepare_withdrawal_from_derive( self, - amount: float, + token_amount: float, currency: Currency, chain_id: ChainID, ) -> PreparedBridgeTx: @@ -129,7 +160,7 @@ async def prepare_withdrawal_from_derive( - Submit with submit_bridge_tx() when ready """ - result = await self.bridge.prepare_withdrawal(token_amount=amount, currency=currency, chain_id=chain_id) + result = await self.bridge.prepare_withdrawal(token_amount=token_amount, currency=currency, chain_id=chain_id) return unwrap_or_raise(result) async def submit_bridge_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResult: @@ -155,7 +186,11 @@ async def submit_bridge_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResul - Call poll_bridge_progress() to wait for cross-chain completion """ - result = await self.bridge.submit_bridge_tx(prepared_tx=prepared_tx) + if prepared_tx.currency == Currency.ETH: + result = await self.standard_bridge.submit_bridge_tx(prepared_tx=prepared_tx) + else: + result = await self.bridge.submit_bridge_tx(prepared_tx=prepared_tx) + return unwrap_or_raise(result) async def poll_bridge_progress(self, tx_result: BridgeTxResult) -> BridgeTxResult: @@ -192,7 +227,11 @@ async def poll_bridge_progress(self, tx_result: BridgeTxResult) -> BridgeTxResul or whether the nonce was reused in another tx. """ - result = await self.bridge.poll_bridge_progress(tx_result=tx_result) + if tx_result.currency == Currency.ETH: + result = await self.standard_bridge.poll_bridge_progress(tx_result=tx_result) + else: + result = await self.bridge.poll_bridge_progress(tx_result=tx_result) + return unwrap_or_raise(result) def get_subscription_id(self, instrument_name: str, group: str = "1", depth: str = "100"): From 4b8d8368bb1a4688db79e74becdb6d3185ae54a2 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Tue, 19 Aug 2025 12:21:36 +0200 Subject: [PATCH 073/101] fix: fee estimation using MIN_PRIORITY_FEE as fallback --- derive_client/_bridge/w3.py | 8 ++++++-- derive_client/constants.py | 1 + 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/derive_client/_bridge/w3.py b/derive_client/_bridge/w3.py index d8ea5ba1..81c38c3a 100644 --- a/derive_client/_bridge/w3.py +++ b/derive_client/_bridge/w3.py @@ -15,7 +15,7 @@ from web3.contract.async_contract import AsyncContract, AsyncContractEvent, AsyncContractFunction from web3.datastructures import AttributeDict -from derive_client.constants import ABI_DATA_DIR, ASSUMED_BRIDGE_GAS_LIMIT, DEFAULT_RPC_ENDPOINTS, GAS_FEE_BUFFER +from derive_client.constants import ABI_DATA_DIR, ASSUMED_BRIDGE_GAS_LIMIT, DEFAULT_RPC_ENDPOINTS, GAS_FEE_BUFFER, MIN_PRIORITY_FEE from derive_client.data_types import ( Address, ChainID, @@ -239,7 +239,11 @@ async def estimate_fees(w3, blocks: int = 20) -> FeeEstimates: estimates = {} for percentile in percentiles: rewards = percentile_rewards[percentile] - estimated_priority_fee = int(statistics.median(rewards)) + non_zero_rewards = list(filter(lambda x: x, rewards)) + if non_zero_rewards: + estimated_priority_fee = int(statistics.median(non_zero_rewards)) + else: + estimated_priority_fee = MIN_PRIORITY_FEE buffered_base_fee = int(latest_base_fee * GAS_FEE_BUFFER) estimated_max_fee = buffered_base_fee + estimated_priority_fee diff --git a/derive_client/constants.py b/derive_client/constants.py index a0882873..e4fedc53 100644 --- a/derive_client/constants.py +++ b/derive_client/constants.py @@ -103,6 +103,7 @@ class EnvConfig(BaseModel, frozen=True): GAS_LIMIT_BUFFER = 1.1 # buffer multiplier to pad gas limit MSG_GAS_LIMIT = 200_000 ASSUMED_BRIDGE_GAS_LIMIT = 1_000_000 +MIN_PRIORITY_FEE = 10_000 PAYLOAD_SIZE = 161 TARGET_SPEED = "FAST" From 0ae5eefca23830f983ee76f854aa212798255149 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Tue, 19 Aug 2025 12:29:39 +0200 Subject: [PATCH 074/101] docs: update docstring for bridge methods in AsyncClient --- derive_client/clients/async_client.py | 29 +++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/derive_client/clients/async_client.py b/derive_client/clients/async_client.py index 76154f5e..fd197b01 100644 --- a/derive_client/clients/async_client.py +++ b/derive_client/clients/async_client.py @@ -83,6 +83,30 @@ async def prepare_standard_tx( source_chain: ChainID, target_chain: ChainID, ) -> PreparedBridgeTx: + """ + Prepare a transaction to bridge tokens to using Standard Bridge. + + This creates a signed transaction ready for submission but does not execute it. + Review the returned PreparedBridgeTx before calling submit_bridge_tx(). + + Args: + token_amount: Amount in token units (e.g., 1.5 USDC, 0.1 ETH) + currency: Currency enum value describing the token to bridge + to: Destination address on the target chain + source_chain: ChainID for the source chain + target_chain: ChainID for the target chain + + Returns: + PreparedBridgeTx: Contains transaction details including: + - tx_hash: Pre-computed transaction hash + - nonce: Transaction nonce for replacement/cancellation + - tx_details: Contract address, method, gas estimates, signed transaction + - currency, amount, source_chain, target_chain, bridge_type: Bridge context + + Use the returned object to: + - Verify contract addresses and gas costs before submission + - Submit with submit_bridge_tx() on approval + """ result = self.standard_bridge.prepare_tx( token_amount=token_amount, @@ -91,6 +115,7 @@ async def prepare_standard_tx( source_chain=source_chain, target_chain=target_chain, ) + return unwrap_or_raise(result) async def prepare_deposit_to_derive( @@ -115,7 +140,7 @@ async def prepare_deposit_to_derive( - tx_hash: Pre-computed transaction hash - nonce: Transaction nonce for replacement/cancellation - tx_details: Contract address, method, gas estimates, signed transaction - - currency, source_chain, target_chain: Bridge context + - currency, amount, source_chain, target_chain, bridge_type: Bridge context Use the returned object to: - Verify contract addresses and gas costs before submission @@ -153,7 +178,7 @@ async def prepare_withdrawal_from_derive( - tx_hash: Pre-computed transaction hash - nonce: Transaction nonce for replacement/cancellation - tx_details: Contract address, method, gas estimates, signed transaction - - currency, source_chain, target_chain: Bridge context + - currency, amount, source_chain, target_chain, bridge_type: Bridge context Use the returned object to: - Verify contract addresses and gas costs before submission From 7c78f784e4e7ffc8d451e7fdcbeb2dc68bbabf80 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Tue, 19 Aug 2025 15:14:14 +0200 Subject: [PATCH 075/101] fix: make FeeHistory.base_fee_per_blob_gas and .blob_gas_used_ratio optional --- derive_client/data_types/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/derive_client/data_types/models.py b/derive_client/data_types/models.py index 7fd86cb4..7c818968 100644 --- a/derive_client/data_types/models.py +++ b/derive_client/data_types/models.py @@ -356,8 +356,8 @@ def __getitem__(self, key: ChainID | int | str) -> list[HttpUrl]: class FeeHistory(BaseModel): base_fee_per_gas: list[Wei] = Field(alias="baseFeePerGas") gas_used_ratio: list[float] = Field(alias="gasUsedRatio") - base_fee_per_blob_gas: list[Wei] = Field(alias="baseFeePerBlobGas") - blob_gas_used_ratio: list[float] = Field(alias="blobGasUsedRatio") + base_fee_per_blob_gas: list[Wei] | None = Field(default=None, alias="baseFeePerBlobGas") + blob_gas_used_ratio: list[float] | None = Field(default=None, alias="blobGasUsedRatio") oldest_block: int = Field(alias="oldestBlock") reward: list[list[Wei]] From 7f1c72a07a4e49c1a213351bdb30c920e0cbac84 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Tue, 19 Aug 2025 15:20:47 +0200 Subject: [PATCH 076/101] feat: run_coroutine_sync --- derive_client/utils/asyncio_sync.py | 56 +++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 derive_client/utils/asyncio_sync.py diff --git a/derive_client/utils/asyncio_sync.py b/derive_client/utils/asyncio_sync.py new file mode 100644 index 00000000..e62fb5ac --- /dev/null +++ b/derive_client/utils/asyncio_sync.py @@ -0,0 +1,56 @@ +import asyncio +import threading +from concurrent.futures import TimeoutError as _TimeoutError +from typing import Any, Optional + + +_bg = {"loop": None, "thread": None, "started": False, "start_ev": threading.Event()} +_start_lock = threading.Lock() + + +def _start_bg_loop() -> None: + if _bg["loop"] is not None and _bg["started"]: + return + with _start_lock: + if _bg["loop"] is not None and _bg["started"]: + return + + def _run() -> None: + loop = asyncio.new_event_loop() + _bg["loop"] = loop + asyncio.set_event_loop(loop) + _bg["start_ev"].set() + _bg["started"] = True + try: + loop.run_forever() + finally: + try: + pending = asyncio.all_tasks(loop=loop) + for t in pending: + t.cancel() + loop.run_until_complete(loop.shutdown_asyncgens()) + finally: + loop.close() + _bg["loop"] = None + _bg["started"] = False + _bg["start_ev"].clear() + + t = threading.Thread(target=_run, name="bg-async-loop", daemon=True) + t.start() + _bg["thread"] = t + _bg["start_ev"].wait(timeout=5) + if not _bg["started"]: + raise RuntimeError("Failed to start background loop") + + +def run_coroutine_sync(coro: object, timeout: Optional[float] = None) -> Any: + """Run coroutine on the single background loop and block until result.""" + + _start_bg_loop() + loop = _bg["loop"] + fut = asyncio.run_coroutine_threadsafe(coro, loop) + try: + return fut.result(timeout) + except _TimeoutError: + fut.cancel() + raise From e2717b96635b53779191e955e2e456a1e5964fdc Mon Sep 17 00:00:00 2001 From: zarathustra Date: Tue, 19 Aug 2025 15:21:25 +0200 Subject: [PATCH 077/101] feat: thin wrappers for AsyncClient bridge methods on HttpClient --- derive_client/clients/http_client.py | 90 ++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/derive_client/clients/http_client.py b/derive_client/clients/http_client.py index 5e5c96fe..0dff40a6 100644 --- a/derive_client/clients/http_client.py +++ b/derive_client/clients/http_client.py @@ -2,8 +2,98 @@ Base class for HTTP client. """ +import functools +from logging import Logger, LoggerAdapter + +from derive_client.data_types import Address, Environment, Currency, ChainID, PreparedBridgeTx, BridgeTxResult +from derive_client.utils.asyncio_sync import run_coroutine_sync + from .base_client import BaseClient +from .async_client import AsyncClient class HttpClient(BaseClient): """HTTP client class.""" + + def __init__( + self, + wallet: Address, + private_key: str, + env: Environment, + logger: Logger | LoggerAdapter | None = None, + verbose: bool = False, + subaccount_id: int | None = None, + ): + super().__init__( + wallet=wallet, + private_key=private_key, + env=env, + logger=logger, + verbose=verbose, + subaccount_id=subaccount_id, + ) + + @functools.cached_property + def _async_client(self) -> AsyncClient: + return AsyncClient( + wallet=self.wallet, + private_key=self.private_key, + env=self.env, + logger=self.logger, + verbose=self.verbose, + subaccount_id=self.subaccount_id, + ) + + def prepare_standard_tx( + self, + token_amount: float, + currency: Currency, + to: Address, + source_chain: ChainID, + target_chain: ChainID, + ) -> PreparedBridgeTx: + """Thin sync wrapper around AsyncClient.prepare_standard_tx.""" + + coroutine = self._async_client.prepare_standard_tx( + token_amount=token_amount, + currency=currency, + to=to, + source_chain=source_chain, + target_chain=target_chain, + ) + + return run_coroutine_sync(coroutine) + + def prepare_deposit_to_derive( + self, + token_amount: float, + currency: Currency, + chain_id: ChainID, + ) -> PreparedBridgeTx: + """Thin sync wrapper around AsyncClient.prepare_deposit_to_derive.""" + + coroutine = self._async_client.prepare_deposit_to_derive(token_amount=token_amount, currency=currency, chain_id=chain_id) + return run_coroutine_sync(coroutine) + + def prepare_withdrawal_from_derive( + self, + token_amount: float, + currency: Currency, + chain_id: ChainID, + ) -> PreparedBridgeTx: + """Thin sync wrapper around AsyncClient.prepare_withdrawal_from_derive.""" + + coroutine = self._async_client.prepare_withdrawal_from_derive(token_amount=token_amount, currency=currency, chain_id=chain_id) + return run_coroutine_sync(coroutine) + + def submit_bridge_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResult: + """Thin sync wrapper around AsyncClient.submit_bridge_tx.""" + + coroutine = self._async_client.submit_bridge_tx(prepared_tx=prepared_tx) + return run_coroutine_sync(coroutine) + + def poll_bridge_progress(self, tx_result: BridgeTxResult) -> BridgeTxResult: + """Thin sync wrapper around AsyncClient.poll_bridge_progress.""" + + coroutine = self._async_client.poll_bridge_progress(tx_result=tx_result) + return run_coroutine_sync(coroutine) From b4f095cd0e262a374cd426461e76025f3b90aeff Mon Sep 17 00:00:00 2001 From: zarathustra Date: Tue, 19 Aug 2025 15:21:56 +0200 Subject: [PATCH 078/101] fix: change subclass AsyncClient from WsClient to BaseClient --- derive_client/clients/async_client.py | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/derive_client/clients/async_client.py b/derive_client/clients/async_client.py index fd197b01..606fbaf0 100644 --- a/derive_client/clients/async_client.py +++ b/derive_client/clients/async_client.py @@ -31,11 +31,10 @@ ) from derive_client.utils import unwrap_or_raise -from .base_client import DeriveJSONRPCException -from .ws_client import WsClient +from .base_client import BaseClient, DeriveJSONRPCException -class AsyncClient(WsClient): +class AsyncClient(BaseClient): """ We use the async client to make async requests to the derive API We us the ws client to make async requests to the derive ws API @@ -61,18 +60,17 @@ def __init__( logger=logger, verbose=verbose, subaccount_id=subaccount_id, - referral_code=None, ) self.message_queues = {} self.connecting = False @functools.cached_property - def bridge(self) -> BridgeClient: + def _bridge(self) -> BridgeClient: return BridgeClient(env=self.env, account=self.signer, wallet=self.wallet, logger=self.logger) @functools.cached_property - def standard_bridge(self) -> StandardBridge: + def _standard_bridge(self) -> StandardBridge: return StandardBridge(self.account, self.logger) async def prepare_standard_tx( @@ -108,7 +106,7 @@ async def prepare_standard_tx( - Submit with submit_bridge_tx() on approval """ - result = self.standard_bridge.prepare_tx( + result = await self._standard_bridge.prepare_tx( token_amount=token_amount, currency=currency, to=to, @@ -153,7 +151,7 @@ async def prepare_deposit_to_derive( "For gas funding of the owner (EOA) use `prepare_standard_tx`." ) - result = await self.bridge.prepare_deposit(token_amount=token_amount, currency=currency, chain_id=chain_id) + result = await self._bridge.prepare_deposit(token_amount=token_amount, currency=currency, chain_id=chain_id) return unwrap_or_raise(result) async def prepare_withdrawal_from_derive( @@ -185,7 +183,7 @@ async def prepare_withdrawal_from_derive( - Submit with submit_bridge_tx() when ready """ - result = await self.bridge.prepare_withdrawal(token_amount=token_amount, currency=currency, chain_id=chain_id) + result = await self._bridge.prepare_withdrawal(token_amount=token_amount, currency=currency, chain_id=chain_id) return unwrap_or_raise(result) async def submit_bridge_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResult: @@ -212,9 +210,9 @@ async def submit_bridge_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResul """ if prepared_tx.currency == Currency.ETH: - result = await self.standard_bridge.submit_bridge_tx(prepared_tx=prepared_tx) + result = await self._standard_bridge.submit_bridge_tx(prepared_tx=prepared_tx) else: - result = await self.bridge.submit_bridge_tx(prepared_tx=prepared_tx) + result = await self._bridge.submit_bridge_tx(prepared_tx=prepared_tx) return unwrap_or_raise(result) @@ -253,9 +251,9 @@ async def poll_bridge_progress(self, tx_result: BridgeTxResult) -> BridgeTxResul """ if tx_result.currency == Currency.ETH: - result = await self.standard_bridge.poll_bridge_progress(tx_result=tx_result) + result = await self._standard_bridge.poll_bridge_progress(tx_result=tx_result) else: - result = await self.bridge.poll_bridge_progress(tx_result=tx_result) + result = await self._bridge.poll_bridge_progress(tx_result=tx_result) return unwrap_or_raise(result) @@ -503,7 +501,7 @@ async def create_order( "order_type": order_type.name.lower(), "mmp": False, "time_in_force": time_in_force.value, - "referral_code": DEFAULT_REFERER if not self.referral_code else self.referral_code, + "referral_code": DEFAULT_REFERER, **signed_action.to_json(), } try: From c9b4deac85e7a393361359d4ad520c8e2996dbab Mon Sep 17 00:00:00 2001 From: zarathustra Date: Tue, 19 Aug 2025 15:22:30 +0200 Subject: [PATCH 079/101] chore: remove bridging methods from BaseClient entirely --- derive_client/clients/base_client.py | 174 ++------------------------- 1 file changed, 12 insertions(+), 162 deletions(-) diff --git a/derive_client/clients/base_client.py b/derive_client/clients/base_client.py index bee06ec6..22fc8b7c 100644 --- a/derive_client/clients/base_client.py +++ b/derive_client/clients/base_client.py @@ -2,7 +2,6 @@ Base Client for the derive dex. """ -import asyncio import json import random from decimal import Decimal @@ -22,19 +21,15 @@ from derive_action_signing.signed_action import SignedAction from derive_action_signing.utils import MAX_INT_32, get_action_nonce, sign_rest_auth_header, utc_now_ms from pydantic import validate_call -from returns.result import Result, safe from web3 import Web3 +from hexbytes import HexBytes -from derive_client._bridge import BridgeClient from derive_client.constants import CONFIGS, DEFAULT_REFERER, PUBLIC_HEADERS, TOKEN_DECIMALS from derive_client.data_types import ( Address, - BridgeTxResult, - ChainID, CollateralAsset, CreateSubAccountData, CreateSubAccountDetails, - Currency, DepositResult, DeriveTxResult, DeriveTxStatus, @@ -55,7 +50,7 @@ ) from derive_client.endpoints import RestAPI from derive_client.exceptions import DeriveJSONRPCException -from derive_client.utils import get_logger, unwrap_or_raise, wait_until +from derive_client.utils import get_logger, wait_until def _is_final_tx(res: DeriveTxResult) -> bool: @@ -65,8 +60,6 @@ def _is_final_tx(res: DeriveTxResult) -> bool: class BaseClient: """Client for the Derive dex.""" - referral_code: str = None - def _create_signature_headers(self): """ Create the signature headers. @@ -81,12 +74,11 @@ def _create_signature_headers(self): def __init__( self, wallet: Address, - private_key: str, + private_key: str | HexBytes, env: Environment, logger: Logger | LoggerAdapter | None = None, verbose: bool = False, subaccount_id: int | None = None, - referral_code: Address | None = None, ): self.verbose = verbose self.env = env @@ -97,7 +89,14 @@ def __init__( self.wallet = wallet self._verify_wallet(wallet) self.subaccount_id = self._determine_subaccount_id(subaccount_id) - self.referral_code = referral_code + + @property + def account(self): + return self.signer + + @property + def private_key(self) -> HexBytes: + return self.account._private_key @property def endpoints(self) -> RestAPI: @@ -140,155 +139,6 @@ def create_account(self, wallet): raise Exception(result_code["error"]) return True - @safe - @validate_call - def deposit_to_derive_result( - self, - chain_id: ChainID, - currency: Currency, - amount: float, - ) -> Result[BridgeTxResult, Exception]: - """ - Submit a deposit into the Derive chain funding contract and return its initial BridgeTxResult - without waiting for completion. - - Parameters: - chain_id (ChainID): The chain you are bridging FROM. - currency (Currency): The asset being bridged. - amount (float): amount to deposit, in human units (will be scaled to Wei). - Returns: - Result[BridgeTxResult, Exception]: A Result object containing either the BridgeTxResult on success, - or an Exception on failure. - """ - - client = BridgeClient(self.env, account=self.signer, wallet=self.wallet, logger=self.logger) - - async def _run(): - future = ( - client.prepare_deposit(token_amount=amount, currency=currency, chain_id=chain_id) - .bind(client.submit_bridge_tx) - .bind(client.poll_bridge_progress) - ) - return await future.awaitable() - - return unwrap_or_raise(asyncio.run(_run())) - - def deposit_to_derive( - self, - chain_id: ChainID, - currency: Currency, - amount: float, - ) -> BridgeTxResult: - """ - Submit a deposit into the Derive chain funding contract and return its initial BridgeTxResult - without waiting for completion. - - Parameters: - chain_id (ChainID): The chain you are bridging FROM. - currency (Currency): The asset being bridged. - amount (float): amount to deposit, in human units (will be scaled to Wei). - Raises: - Exception: If the deposit fails. - Returns: - BridgeTxResult: The result of the deposit operation. - """ - - result = self.deposit_to_derive_result(chain_id=chain_id, currency=currency, amount=amount) - return unwrap_or_raise(result) - - @safe - @validate_call - def withdraw_from_derive_result( - self, - chain_id: ChainID, - currency: Currency, - amount: float, - ) -> Result[BridgeTxResult, Exception]: - """ - Submit a withdrawal from the Derive chain funding contract and return its initial BridgeTxResult - without waiting for completion. - - Parameters: - chain_id (ChainID): The chain you are bridging TO. - currency (Currency): The asset being bridged. - amount (float): amount to withdraw, in human units (will be scaled to Wei). - Returns: - Result[BridgeTxResult, Exception]: A Result object containing either the BridgeTxResult on success, - or an Exception on failure. - """ - - client = BridgeClient(self.env, account=self.signer, wallet=self.wallet, logger=self.logger) - - async def _run(): - future = ( - client.prepare_withdrawal(token_amount=amount, currency=currency, chain_id=chain_id) - .bind(client.submit_bridge_tx) - .bind(client.poll_bridge_progress) - ) - return await future.awaitable() - - return unwrap_or_raise(asyncio.run(_run())) - - def withdraw_from_derive( - self, - chain_id: ChainID, - currency: Currency, - amount: float, - ) -> BridgeTxResult: - """ - Submit a withdrawal from the Derive chain funding contract and return its initial BridgeTxResult - without waiting for completion. - - Parameters: - chain_id (ChainID): The chain you are bridging TO. - currency (Currency): The asset being bridged. - amount (float): amount to withdraw, in human units (will be scaled to Wei). - Raises: - Exception: If the withdrawal fails. - Returns: - BridgeTxResult: The result of the withdrawal operation. - """ - - result = self.withdraw_from_derive_result(chain_id=chain_id, currency=currency, amount=amount) - return unwrap_or_raise(result) - - @safe - @validate_call - def poll_bridge_progress_result(self, tx_result: BridgeTxResult) -> Result[BridgeTxResult, Exception]: - """ - Given a pending BridgeTxResult, return a new BridgeTxResult with updated status. - Raises AlreadyFinalizedError if tx_result is not in PENDING status. - - Parameters: - tx_result (BridgeTxResult): the result to refresh. - Returns: - Result[BridgeTxResult, Exception]: A Result object containing either the BridgeTxResult on success, - or an Exception on failure. - """ - - client = BridgeClient(account=self.signer, wallet=self.wallet, logger=self.logger) - - async def _run(): - return await client.poll_bridge_progress(tx_result=tx_result) - - return asyncio.run(_run()) - - def poll_bridge_progress(self, tx_result: BridgeTxResult) -> BridgeTxResult: - """ - Given a pending BridgeTxResult, return a new BridgeTxResult with updated status. - Raises AlreadyFinalizedError if tx_result is not in PENDING status. - - Parameters: - tx_result (BridgeTxResult): the result to refresh. - Raises: - Exception: If the polling fails. - Returns: - BridgeTxResult: The result of the polling operation. - """ - - result = self.poll_bridge_progress_result(tx_result=tx_result) - return unwrap_or_raise(result) - def fetch_instruments( self, expired=False, @@ -396,7 +246,7 @@ def create_order( "order_type": order_type.name.lower(), "mmp": False, "time_in_force": time_in_force.value, - "referral_code": DEFAULT_REFERER if not self.referral_code else self.referral_code, + "referral_code": DEFAULT_REFERER, **signed_action.to_json(), } From c01164a74f1bff5ebd94ca68278c804720a7b779 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Tue, 19 Aug 2025 15:22:54 +0200 Subject: [PATCH 080/101] fix: type annotations on BridgeClient --- derive_client/_bridge/client.py | 36 ++++++++++++++++----------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index f17b5db0..afac558b 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -11,9 +11,9 @@ from eth_account import Account from returns.future import future_safe from returns.io import IOResult -from web3 import Web3 -from web3.contract import Contract -from web3.contract.contract import ContractFunction +from web3 import AsyncWeb3 +from web3.contract import AsyncContract +from web3.contract.async_contract import AsyncContractFunction from web3.datastructures import AttributeDict from web3.types import HexBytes, LogReceipt, TxReceipt @@ -83,31 +83,31 @@ ) -def _load_vault_contract(w3: Web3, token_data: NonMintableTokenData) -> Contract: +def _load_vault_contract(w3: AsyncWeb3, token_data: NonMintableTokenData) -> AsyncContract: path = NEW_VAULT_ABI_PATH if token_data.isNewBridge else OLD_VAULT_ABI_PATH abi = json.loads(path.read_text()) return get_contract(w3=w3, address=token_data.Vault, abi=abi) -def _load_controller_contract(w3: Web3, token_data: MintableTokenData) -> Contract: +def _load_controller_contract(w3: AsyncWeb3, token_data: MintableTokenData) -> AsyncContract: path = CONTROLLER_ABI_PATH if token_data.isNewBridge else CONTROLLER_V0_ABI_PATH abi = json.loads(path.read_text()) return get_contract(w3=w3, address=token_data.Controller, abi=abi) -def _load_deposit_contract(w3: Web3, token_data: MintableTokenData) -> Contract: +def _load_deposit_contract(w3: AsyncWeb3, token_data: MintableTokenData) -> AsyncContract: address = token_data.LyraTSAShareHandlerDepositHook abi = json.loads(DEPOSIT_HOOK_ABI_PATH.read_text()) return get_contract(w3=w3, address=address, abi=abi) -def _load_light_account(w3: Web3, wallet: Address) -> Contract: +def _load_light_account(w3: AsyncWeb3, wallet: Address) -> AsyncContract: abi = json.loads(LIGHT_ACCOUNT_ABI_PATH.read_text()) return get_contract(w3=w3, address=wallet, abi=abi) def _get_min_fees( - bridge_contract: Contract, + bridge_contract: AsyncContract, connector: Address, token_data: NonMintableTokenData | MintableTokenData, ) -> int: @@ -137,11 +137,11 @@ def __init__(self, env: Environment, account: Account, wallet: Address, logger: self.logger = logger @property - def derive_w3(self): + def derive_w3(self) -> AsyncWeb3: return self.w3s[ChainID.DERIVE] @property - def private_key(self) -> str: + def private_key(self) -> HexBytes: """Private key of the owner (EOA) of the smart contract funding account.""" return self.account._private_key @@ -163,7 +163,7 @@ async def verify_owner(self): "primary wallet owner." ) - def get_deposit_helper(self, chain_id: ChainID) -> Contract: + def get_deposit_helper(self, chain_id: ChainID) -> AsyncContract: match chain_id: case ChainID.ARBITRUM: @@ -179,7 +179,7 @@ def get_deposit_helper(self, chain_id: ChainID) -> Contract: return get_contract(w3=self.w3s[chain_id], address=address, abi=abi) @functools.cached_property - def withdraw_wrapper(self) -> Contract: + def withdraw_wrapper(self) -> AsyncContract: address = WITHDRAW_WRAPPER_V2 abi = json.loads(WITHDRAW_WRAPPER_V2_ABI_PATH.read_text()) return get_contract(w3=self.derive_w3, address=address, abi=abi) @@ -270,7 +270,7 @@ def _resolve_socket_route( async def _prepare_tx( self, - func: ContractFunction, + func: AsyncContractFunction, value: int, context: BridgeContext, ) -> PreparedBridgeTx: @@ -437,7 +437,7 @@ async def _prepare_layerzero_deposit(self, amount: int, context: BridgeContext) ) # build the send tx - receiver_bytes32 = Web3.to_bytes(hexstr=self.wallet).rjust(32, b"\x00") + receiver_bytes32 = AsyncWeb3.to_bytes(hexstr=self.wallet).rjust(32, b"\x00") kwargs = { "dstEid": LayerZeroChainIDv2.DERIVE.value, @@ -604,8 +604,8 @@ def matching_message_id(log: AttributeDict) -> bool: return await wait_for_event(context.target_w3, filter_params, condition=matching_message_id, logger=self.logger) def _prepare_new_style_deposit( - self, token_data: NonMintableTokenData, amount: int, context: BridgeContext - ) -> tuple[ContractFunction, int]: + self, token_data: NonMintableTokenData, amount: int, context: BridgeContext, + ) -> tuple[AsyncContractFunction, int]: vault_contract = _load_vault_contract(w3=self.w3s[context.source_chain], token_data=token_data) connector = token_data.connectors[ChainID.DERIVE][TARGET_SPEED] @@ -622,8 +622,8 @@ def _prepare_new_style_deposit( return func, fees_func def _prepare_old_style_deposit( - self, token_data: NonMintableTokenData, amount: int, context: BridgeContext - ) -> tuple[ContractFunction, int]: + self, token_data: NonMintableTokenData, amount: int, context: BridgeContext, + ) -> tuple[AsyncContractFunction, int]: vault_contract = _load_vault_contract(w3=self.w3s[context.source_chain], token_data=token_data) connector = token_data.connectors[ChainID.DERIVE][TARGET_SPEED] From 26beaf374745d4f6729f60d63dc7a1104872b6ea Mon Sep 17 00:00:00 2001 From: zarathustra Date: Tue, 19 Aug 2025 15:45:18 +0200 Subject: [PATCH 081/101] fix: use HttpClient as base class for DeriveClient, and update cli command for deposit and withdraw --- derive_client/_bridge/w3.py | 2 +- derive_client/cli.py | 13 +++++++++---- derive_client/derive.py | 4 ++-- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/derive_client/_bridge/w3.py b/derive_client/_bridge/w3.py index 81c38c3a..4f0d2c40 100644 --- a/derive_client/_bridge/w3.py +++ b/derive_client/_bridge/w3.py @@ -265,7 +265,7 @@ async def preflight_native_balance_check( chain_id = ChainID(await w3.eth.chain_id) ratio = balance / total_cost * 100 raise InsufficientNativeBalance( - f"Insufficient funds on {chain_id}: balance={balance}, required={total_cost} {ratio:.2f}% available " + f"Insufficient funds on {chain_id.name} ({chain_id}): balance={balance}, required={total_cost} {ratio:.2f}% available " f"(includes value={value} and assumed gas limit={ASSUMED_BRIDGE_GAS_LIMIT} at {max_fee_per_gas} wei/gas)", balance=balance, chain_id=chain_id, diff --git a/derive_client/cli.py b/derive_client/cli.py index 8c29cfd3..c286bec5 100644 --- a/derive_client/cli.py +++ b/derive_client/cli.py @@ -13,6 +13,7 @@ from derive_client.analyser import PortfolioAnalyser from derive_client.clients.base_client import BaseClient +from derive_client.clients.http_client import HttpClient from derive_client.data_types import ( ChainID, CollateralAsset, @@ -155,9 +156,11 @@ def deposit(ctx, chain_id, currency, amount): chain_id = ChainID[chain_id] currency = Currency[currency] - client: BaseClient = ctx.obj["client"] + client: HttpClient = ctx.obj["client"] - bridge_tx_result = client.deposit_to_derive(chain_id=chain_id, currency=currency, amount=amount) + prepared_tx = client.prepare_deposit_to_derive(chain_id=chain_id, currency=currency, token_amount=amount) + tx_result = client.submit_bridge_tx(prepared_tx=prepared_tx) + bridge_tx_result = client.poll_bridge_progress(tx_result=tx_result) match bridge_tx_result.status: case TxStatus.SUCCESS: @@ -204,9 +207,11 @@ def withdraw(ctx, chain_id, currency, amount): chain_id = ChainID[chain_id] currency = Currency[currency] - client: DeriveClient = ctx.obj["client"] + client: HttpClient = ctx.obj["client"] - bridge_tx_result = client.withdraw_from_derive(chain_id=chain_id, currency=currency, amount=amount) + prepared_tx = client.prepare_withdrawal_from_derive(chain_id=chain_id, currency=currency, token_amount=amount) + tx_result = client.submit_bridge_tx(prepared_tx=prepared_tx) + bridge_tx_result = client.poll_bridge_progress(tx_result=tx_result) match bridge_tx_result.status: case TxStatus.SUCCESS: diff --git a/derive_client/derive.py b/derive_client/derive.py index 6260c06d..fdf693c0 100644 --- a/derive_client/derive.py +++ b/derive_client/derive.py @@ -5,7 +5,7 @@ import pandas as pd from web3 import Web3 -from derive_client.clients import BaseClient, HttpClient +from derive_client.clients import HttpClient # we set to show 4 decimal places pd.options.display.float_format = '{:,.4f}'.format @@ -15,7 +15,7 @@ def to_32byte_hex(val): return Web3.to_hex(Web3.to_bytes(val).rjust(32, b"\0")) -class DeriveClient(BaseClient): +class DeriveClient(HttpClient): """Client for the derive dex.""" def _create_signature_headers(self): From f21d374a0a1ed7c5a5273b449a661b731d42d1be Mon Sep 17 00:00:00 2001 From: zarathustra Date: Tue, 19 Aug 2025 16:10:38 +0200 Subject: [PATCH 082/101] feat: StandardBridgeRelayFailed --- derive_client/_bridge/standard_bridge.py | 15 +++++++++++++-- derive_client/exceptions.py | 8 ++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/derive_client/_bridge/standard_bridge.py b/derive_client/_bridge/standard_bridge.py index 44ffb1fc..4464a1d1 100644 --- a/derive_client/_bridge/standard_bridge.py +++ b/derive_client/_bridge/standard_bridge.py @@ -45,7 +45,7 @@ RESOLVED_DELEGATE_PROXY, MSG_GAS_LIMIT, ) -from derive_client.exceptions import BridgeEventParseError +from derive_client.exceptions import BridgeEventParseError, StandardBridgeRelayFailed def _load_l1_contract(w3: AsyncWeb3) -> AsyncContract: @@ -292,10 +292,21 @@ async def _fetch_standard_event_log(self, tx_result: BridgeTxResult) -> LogRecei relayed_task = asyncio.create_task(wait_for_event(target_w3, filter_params, logger=self.logger)) failed_task = asyncio.create_task(wait_for_event(target_w3, failed_filter_params, logger=self.logger)) done, pending = await asyncio.wait([relayed_task, failed_task], return_when=asyncio.FIRST_COMPLETED) + for task in pending: task.cancel() if failed_task in done: - raise + event_log = done.pop().result() # reraises Exceptions (i.e. TimeoutError), and in this scenario not raise StandardBridgeRelayFailed + raise StandardBridgeRelayFailed( + "The relay was attempted but reverted on L2. " + "Likely causes are out-of-gas, non-standard token implementation, or target contract reversion.\n" + "Action:\n" + "- Inspect the L2 tx receipt logs for the revert reason.\n" + "- If out-of-gas, resubmit with higher _minGasLimit.\n" + "- If token mismatch, check that the L2 token contract matches the expected bridgeable ERC20.\n" + "- If paused/reverted, retry after resolving the underlying contract state.", + event_log=event_log, + ) event_log = done.pop().result() diff --git a/derive_client/exceptions.py b/derive_client/exceptions.py index ca7a3083..251d2b7e 100644 --- a/derive_client/exceptions.py +++ b/derive_client/exceptions.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: + from web3.types import LogReceipt from derive_client.data_types import BridgeTxResult, ChainID, Wei, FeeEstimate @@ -112,3 +113,10 @@ def __init__(self, message: str, *, tx_result: BridgeTxResult): def cause(self) -> Exception | None: """Provides access to the orignal Exception.""" return self.__cause__ + +class StandardBridgeRelayFailed(Exception): + """Raised when the L2 messenger emits FailedRelayedMessage.""" + + def __init__(self, message: str, *, event_log: LogReceipt): + super().__init__(message) + self.event_log = event_log From 991e2ae85ade8ae6539c9e71ccaf384f1edc1adc Mon Sep 17 00:00:00 2001 From: zarathustra Date: Tue, 19 Aug 2025 16:20:04 +0200 Subject: [PATCH 083/101] feat: BridgeEventTimeout --- derive_client/_bridge/w3.py | 10 +++++++--- derive_client/exceptions.py | 4 ++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/derive_client/_bridge/w3.py b/derive_client/_bridge/w3.py index 4f0d2c40..5cc39527 100644 --- a/derive_client/_bridge/w3.py +++ b/derive_client/_bridge/w3.py @@ -34,6 +34,7 @@ NoAvailableRPC, TransactionDropped, TxPendingTimeout, + BridgeEventTimeout, ) from derive_client.utils.logger import get_logger from derive_client.utils.retry import exp_backoff_retry @@ -468,7 +469,7 @@ async def iter_events( await asyncio.sleep(poll_interval) -async def wait_for_event( +async def wait_for_bridge_event( w3: AsyncWeb3, filter_params: dict, *, @@ -478,9 +479,12 @@ async def wait_for_event( timeout: float = 300.0, logger: Logger, ) -> AttributeDict: - """Return the first log from iter_events, or raise TimeoutError after `timeout` seconds.""" + """Wait for the first matching bridge-related log on the target chain or raise BridgeEventTimeout.""" - return await anext(iter_events(**locals())) + try: + return await anext(iter_events(**locals())) + except TimeoutError as e: + raise BridgeEventTimeout("Timed out waiting for target chain bridge event") from e def make_filter_params( diff --git a/derive_client/exceptions.py b/derive_client/exceptions.py index 251d2b7e..a4106f55 100644 --- a/derive_client/exceptions.py +++ b/derive_client/exceptions.py @@ -102,6 +102,10 @@ class TransactionDropped(Exception): """Raised when the transaction the transaction is no longer in the mempool, likely dropped.""" +class BridgeEventTimeout(Exception): + """Raised when no matching bridge event was seen before deadline.""" + + class PartialBridgeResult(Exception): """Raised after submission when the bridge pipeline fails""" From e26069ee5c1b2687bae79b031ea0d40b5d633923 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Tue, 19 Aug 2025 16:29:04 +0200 Subject: [PATCH 084/101] chore: make fmt lint --- derive_client/_bridge/client.py | 10 +++- derive_client/_bridge/standard_bridge.py | 71 +++++++++++++----------- derive_client/_bridge/w3.py | 18 ++++-- derive_client/cli.py | 6 +- derive_client/clients/async_client.py | 1 - derive_client/clients/base_client.py | 2 +- derive_client/clients/http_client.py | 16 ++++-- derive_client/exceptions.py | 14 ++++- derive_client/utils/asyncio_sync.py | 1 - 9 files changed, 87 insertions(+), 52 deletions(-) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index afac558b..269a834d 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -604,7 +604,10 @@ def matching_message_id(log: AttributeDict) -> bool: return await wait_for_event(context.target_w3, filter_params, condition=matching_message_id, logger=self.logger) def _prepare_new_style_deposit( - self, token_data: NonMintableTokenData, amount: int, context: BridgeContext, + self, + token_data: NonMintableTokenData, + amount: int, + context: BridgeContext, ) -> tuple[AsyncContractFunction, int]: vault_contract = _load_vault_contract(w3=self.w3s[context.source_chain], token_data=token_data) @@ -622,7 +625,10 @@ def _prepare_new_style_deposit( return func, fees_func def _prepare_old_style_deposit( - self, token_data: NonMintableTokenData, amount: int, context: BridgeContext, + self, + token_data: NonMintableTokenData, + amount: int, + context: BridgeContext, ) -> tuple[AsyncContractFunction, int]: vault_contract = _load_vault_contract(w3=self.w3s[context.source_chain], token_data=token_data) diff --git a/derive_client/_bridge/standard_bridge.py b/derive_client/_bridge/standard_bridge.py index 4464a1d1..0a1a5c90 100644 --- a/derive_client/_bridge/standard_bridge.py +++ b/derive_client/_bridge/standard_bridge.py @@ -1,31 +1,42 @@ - -import json import asyncio +import json from logging import Logger from eth_account import Account +from eth_utils import keccak +from returns.future import future_safe +from returns.io import IOResult from web3 import AsyncWeb3 from web3.contract import AsyncContract from web3.types import HexBytes, LogReceipt, TxReceipt -from returns.future import future_safe -from returns.io import IOResult -from eth_utils import keccak +from derive_client.constants import ( + L1_CHUG_SPLASH_PROXY, + L1_CROSS_DOMAIN_MESSENGER_ABI_PATH, + L1_STANDARD_BRIDGE_ABI_PATH, + L2_CROSS_DOMAIN_MESSENGER_ABI_PATH, + L2_CROSS_DOMAIN_MESSENGER_PROXY, + L2_STANDARD_BRIDGE_ABI_PATH, + L2_STANDARD_BRIDGE_PROXY, + MSG_GAS_LIMIT, + RESOLVED_DELEGATE_PROXY, +) from derive_client.data_types import ( Address, - ChainID, + BridgeTxDetails, BridgeTxResult, + BridgeType, + ChainID, Currency, - BridgeTxDetails, PreparedBridgeTx, - BridgeType, TxResult, ) -from derive_client.exceptions import PartialBridgeResult +from derive_client.exceptions import BridgeEventParseError, PartialBridgeResult, StandardBridgeRelayFailed from derive_client.utils.w3 import to_base_units + from .w3 import ( - encode_abi, build_standard_transaction, + encode_abi, get_contract, get_w3_connections, make_filter_params, @@ -34,18 +45,6 @@ wait_for_event, wait_for_tx_finality, ) -from derive_client.constants import ( - L1_STANDARD_BRIDGE_ABI_PATH, - L2_STANDARD_BRIDGE_ABI_PATH, - L1_CROSS_DOMAIN_MESSENGER_ABI_PATH, - L1_CHUG_SPLASH_PROXY, - L2_STANDARD_BRIDGE_PROXY, - L2_CROSS_DOMAIN_MESSENGER_ABI_PATH, - L2_CROSS_DOMAIN_MESSENGER_PROXY, - RESOLVED_DELEGATE_PROXY, - MSG_GAS_LIMIT, -) -from derive_client.exceptions import BridgeEventParseError, StandardBridgeRelayFailed def _load_l1_contract(w3: AsyncWeb3) -> AsyncContract: @@ -98,11 +97,18 @@ async def prepare_tx( target_chain: ChainID, ) -> IOResult[PreparedBridgeTx, Exception]: - if currency is not Currency.ETH or source_chain is not ChainID.ETH or target_chain is not ChainID.DERIVE or to != self.account.address: + if ( + currency is not Currency.ETH + or source_chain is not ChainID.ETH + or target_chain is not ChainID.DERIVE + or to != self.account.address + ): raise NotImplementedError("Only ETH transfers from Ethereum to Derive EOA are currently supported.") amount: int = to_base_units(token_amount=token_amount, currency=currency) - prepared_tx = await self._prepare_tx(amount=amount, currency=currency, to=to, source_chain=source_chain, target_chain=target_chain) + prepared_tx = await self._prepare_tx( + amount=amount, currency=currency, to=to, source_chain=source_chain, target_chain=target_chain + ) return prepared_tx @@ -153,7 +159,7 @@ async def _prepare_tx( tx_gas_cost = tx["gas"] * tx["maxFeePerGas"] if value < tx_gas_cost: - msg = f"⚠️ Bridge tx value {value} is smaller than gas cost {tx_gas_cost} (~{tx_gas_cost/value:.2f}x value)." + msg = f"⚠️ Bridge tx value {value} is smaller than gas cost {tx_gas_cost} (~{tx_gas_cost/value:.2f}x value)" self.logger.warning(msg) signed_tx = sign_tx(w3=w3, tx=tx, private_key=self.private_key) @@ -184,7 +190,7 @@ async def _send_bridge_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResult # record on target chain where we should start polling target_from_block = await target_w3.eth.block_number - + signed_tx = prepared_tx.tx_details.signed_tx tx_hash = await send_tx(w3=source_w3, signed_tx=signed_tx) source_tx = TxResult(tx_hash=tx_hash) @@ -241,7 +247,7 @@ async def _confirm_target_tx(self, tx_result: BridgeTxResult) -> TxReceipt: async def _fetch_standard_event_log(self, tx_result: BridgeTxResult) -> LogReceipt: source_event = self.l1_messenger_proxy.events.SentMessage() - + target_w3 = self.w3s[tx_result.target_chain] try: source_event_log = source_event.process_log(tx_result.source_tx.tx_receipt.logs[3]) @@ -269,7 +275,7 @@ async def _fetch_standard_event_log(self, tx_result: BridgeTxResult) -> LogRecei msg_hash = keccak(encode_abi(func)) tx_result.event_id = msg_hash.hex() - self.logger.info(f"🗝️ Computed msgHash: {tx_result.event_id}") + self.logger.info(f"🗝️ Computed msgHash: {tx_result.event_id}") target_event = self.l2_messenger_proxy.events.RelayedMessage() failed_target_event = self.l2_messenger_proxy.events.FailedRelayedMessage() @@ -285,9 +291,7 @@ async def _fetch_standard_event_log(self, tx_result: BridgeTxResult) -> LogRecei argument_filters={"msgHash": msg_hash}, ) - self.logger.info( - f"🔍 Listening for msgHash on [{tx_result.target_chain.name}] at {target_event.address}" - ) + self.logger.info(f"🔍 Listening for msgHash on [{tx_result.target_chain.name}] at {target_event.address}") relayed_task = asyncio.create_task(wait_for_event(target_w3, filter_params, logger=self.logger)) failed_task = asyncio.create_task(wait_for_event(target_w3, failed_filter_params, logger=self.logger)) @@ -296,7 +300,8 @@ async def _fetch_standard_event_log(self, tx_result: BridgeTxResult) -> LogRecei for task in pending: task.cancel() if failed_task in done: - event_log = done.pop().result() # reraises Exceptions (i.e. TimeoutError), and in this scenario not raise StandardBridgeRelayFailed + # reraises Exceptions (i.e. BridgeEventTimeout), and in this scenario not raise StandardBridgeRelayFailed + event_log = done.pop().result() raise StandardBridgeRelayFailed( "The relay was attempted but reverted on L2. " "Likely causes are out-of-gas, non-standard token implementation, or target contract reversion.\n" @@ -307,7 +312,7 @@ async def _fetch_standard_event_log(self, tx_result: BridgeTxResult) -> LogRecei "- If paused/reverted, retry after resolving the underlying contract state.", event_log=event_log, ) - + event_log = done.pop().result() return event_log diff --git a/derive_client/_bridge/w3.py b/derive_client/_bridge/w3.py index 5cc39527..a7180fff 100644 --- a/derive_client/_bridge/w3.py +++ b/derive_client/_bridge/w3.py @@ -15,7 +15,13 @@ from web3.contract.async_contract import AsyncContract, AsyncContractEvent, AsyncContractFunction from web3.datastructures import AttributeDict -from derive_client.constants import ABI_DATA_DIR, ASSUMED_BRIDGE_GAS_LIMIT, DEFAULT_RPC_ENDPOINTS, GAS_FEE_BUFFER, MIN_PRIORITY_FEE +from derive_client.constants import ( + ABI_DATA_DIR, + ASSUMED_BRIDGE_GAS_LIMIT, + DEFAULT_RPC_ENDPOINTS, + GAS_FEE_BUFFER, + MIN_PRIORITY_FEE, +) from derive_client.data_types import ( Address, ChainID, @@ -28,13 +34,13 @@ Wei, ) from derive_client.exceptions import ( + BridgeEventTimeout, FinalityTimeout, InsufficientNativeBalance, InsufficientTokenBalance, NoAvailableRPC, TransactionDropped, TxPendingTimeout, - BridgeEventTimeout, ) from derive_client.utils.logger import get_logger from derive_client.utils.retry import exp_backoff_retry @@ -254,7 +260,10 @@ async def estimate_fees(w3, blocks: int = 20) -> FeeEstimates: async def preflight_native_balance_check( - w3: AsyncWeb3, fee_estimate: FeeEstimate, account: Account, value: Wei + w3: AsyncWeb3, + fee_estimate: FeeEstimate, + account: Account, + value: Wei, ) -> None: balance = await w3.eth.get_balance(account.address) max_fee_per_gas = fee_estimate.max_fee_per_gas @@ -266,7 +275,8 @@ async def preflight_native_balance_check( chain_id = ChainID(await w3.eth.chain_id) ratio = balance / total_cost * 100 raise InsufficientNativeBalance( - f"Insufficient funds on {chain_id.name} ({chain_id}): balance={balance}, required={total_cost} {ratio:.2f}% available " + f"Insufficient funds on {chain_id.name} ({chain_id}): " + f"balance={balance}, required={total_cost} {ratio:.2f}% available " f"(includes value={value} and assumed gas limit={ASSUMED_BRIDGE_GAS_LIMIT} at {max_fee_per_gas} wei/gas)", balance=balance, chain_id=chain_id, diff --git a/derive_client/cli.py b/derive_client/cli.py index c286bec5..23691420 100644 --- a/derive_client/cli.py +++ b/derive_client/cli.py @@ -12,8 +12,6 @@ from rich import print from derive_client.analyser import PortfolioAnalyser -from derive_client.clients.base_client import BaseClient -from derive_client.clients.http_client import HttpClient from derive_client.data_types import ( ChainID, CollateralAsset, @@ -156,7 +154,7 @@ def deposit(ctx, chain_id, currency, amount): chain_id = ChainID[chain_id] currency = Currency[currency] - client: HttpClient = ctx.obj["client"] + client: DeriveClient = ctx.obj["client"] prepared_tx = client.prepare_deposit_to_derive(chain_id=chain_id, currency=currency, token_amount=amount) tx_result = client.submit_bridge_tx(prepared_tx=prepared_tx) @@ -207,7 +205,7 @@ def withdraw(ctx, chain_id, currency, amount): chain_id = ChainID[chain_id] currency = Currency[currency] - client: HttpClient = ctx.obj["client"] + client: DeriveClient = ctx.obj["client"] prepared_tx = client.prepare_withdrawal_from_derive(chain_id=chain_id, currency=currency, token_amount=amount) tx_result = client.submit_bridge_tx(prepared_tx=prepared_tx) diff --git a/derive_client/clients/async_client.py b/derive_client/clients/async_client.py index 606fbaf0..6de0a838 100644 --- a/derive_client/clients/async_client.py +++ b/derive_client/clients/async_client.py @@ -14,7 +14,6 @@ from derive_client._bridge import BridgeClient from derive_client._bridge.standard_bridge import StandardBridge - from derive_client.constants import DEFAULT_REFERER, TEST_PRIVATE_KEY from derive_client.data_types import ( Address, diff --git a/derive_client/clients/base_client.py b/derive_client/clients/base_client.py index 22fc8b7c..c63fce43 100644 --- a/derive_client/clients/base_client.py +++ b/derive_client/clients/base_client.py @@ -20,9 +20,9 @@ ) from derive_action_signing.signed_action import SignedAction from derive_action_signing.utils import MAX_INT_32, get_action_nonce, sign_rest_auth_header, utc_now_ms +from hexbytes import HexBytes from pydantic import validate_call from web3 import Web3 -from hexbytes import HexBytes from derive_client.constants import CONFIGS, DEFAULT_REFERER, PUBLIC_HEADERS, TOKEN_DECIMALS from derive_client.data_types import ( diff --git a/derive_client/clients/http_client.py b/derive_client/clients/http_client.py index 0dff40a6..2b579ce6 100644 --- a/derive_client/clients/http_client.py +++ b/derive_client/clients/http_client.py @@ -5,11 +5,11 @@ import functools from logging import Logger, LoggerAdapter -from derive_client.data_types import Address, Environment, Currency, ChainID, PreparedBridgeTx, BridgeTxResult +from derive_client.data_types import Address, BridgeTxResult, ChainID, Currency, Environment, PreparedBridgeTx from derive_client.utils.asyncio_sync import run_coroutine_sync -from .base_client import BaseClient from .async_client import AsyncClient +from .base_client import BaseClient class HttpClient(BaseClient): @@ -72,7 +72,11 @@ def prepare_deposit_to_derive( ) -> PreparedBridgeTx: """Thin sync wrapper around AsyncClient.prepare_deposit_to_derive.""" - coroutine = self._async_client.prepare_deposit_to_derive(token_amount=token_amount, currency=currency, chain_id=chain_id) + coroutine = self._async_client.prepare_deposit_to_derive( + token_amount=token_amount, + currency=currency, + chain_id=chain_id, + ) return run_coroutine_sync(coroutine) def prepare_withdrawal_from_derive( @@ -83,7 +87,11 @@ def prepare_withdrawal_from_derive( ) -> PreparedBridgeTx: """Thin sync wrapper around AsyncClient.prepare_withdrawal_from_derive.""" - coroutine = self._async_client.prepare_withdrawal_from_derive(token_amount=token_amount, currency=currency, chain_id=chain_id) + coroutine = self._async_client.prepare_withdrawal_from_derive( + token_amount=token_amount, + currency=currency, + chain_id=chain_id, + ) return run_coroutine_sync(coroutine) def submit_bridge_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResult: diff --git a/derive_client/exceptions.py b/derive_client/exceptions.py index a4106f55..e5be89c0 100644 --- a/derive_client/exceptions.py +++ b/derive_client/exceptions.py @@ -6,7 +6,8 @@ if TYPE_CHECKING: from web3.types import LogReceipt - from derive_client.data_types import BridgeTxResult, ChainID, Wei, FeeEstimate + + from derive_client.data_types import BridgeTxResult, ChainID, FeeEstimate, Wei class ApiException(Exception): @@ -62,7 +63,15 @@ class NoAvailableRPC(Exception): class InsufficientNativeBalance(Exception): """Raised when the native currency balance is insufficient for gas and/or value transfer.""" - def __init__(self, message: str, *, chain_id: ChainID, balance: Wei, assumed_gas_limit: Wei, fee_estimate: FeeEstimate): + def __init__( + self, + message: str, + *, + chain_id: ChainID, + balance: Wei, + assumed_gas_limit: Wei, + fee_estimate: FeeEstimate, + ): super().__init__(message) self.chain_id = chain_id self.balance = balance @@ -118,6 +127,7 @@ def cause(self) -> Exception | None: """Provides access to the orignal Exception.""" return self.__cause__ + class StandardBridgeRelayFailed(Exception): """Raised when the L2 messenger emits FailedRelayedMessage.""" diff --git a/derive_client/utils/asyncio_sync.py b/derive_client/utils/asyncio_sync.py index e62fb5ac..37b3a48b 100644 --- a/derive_client/utils/asyncio_sync.py +++ b/derive_client/utils/asyncio_sync.py @@ -3,7 +3,6 @@ from concurrent.futures import TimeoutError as _TimeoutError from typing import Any, Optional - _bg = {"loop": None, "thread": None, "started": False, "start_ev": threading.Event()} _start_lock = threading.Lock() From 0430bef42f62f56a10ba1e921e854b205607dd40 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Tue, 19 Aug 2025 18:59:12 +0200 Subject: [PATCH 085/101] feat: CURRENCY_DECIMALS --- derive_client/constants.py | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/derive_client/constants.py b/derive_client/constants.py index e4fedc53..4131b670 100644 --- a/derive_client/constants.py +++ b/derive_client/constants.py @@ -6,7 +6,7 @@ from pydantic import BaseModel -from derive_client.data_types import Environment, UnderlyingCurrency +from derive_client.data_types import Environment, UnderlyingCurrency, Currency class ContractAddresses(BaseModel, frozen=True): @@ -125,6 +125,35 @@ class EnvConfig(BaseModel, frozen=True): UnderlyingCurrency.DRV: 18, } +CURRENCY_DECIMALS = { + Currency.ETH: 18, + Currency.weETH: 18, + Currency.rswETH: 18, + Currency.rsETH: 18, + Currency.USDe: 18, + Currency.deUSD: 18, + Currency.PYUSD: 6, + Currency.sUSDe: 18, + Currency.SolvBTC: 18, + Currency.SolvBTCBBN: 18, + Currency.LBTC: 8, + Currency.OP: 18, + Currency.DAI: 18, + Currency.sDAI: 18, + Currency.cbBTC: 8, + Currency.eBTC: 8, + Currency.AAVE: 18, + Currency.OLAS: 18, + Currency.DRV: 18, + Currency.WBTC: 8, + Currency.WETH: 18, + Currency.USDC: 6, + Currency.USDT: 6, + Currency.wstETH: 18, + Currency.USDCe: 6, + Currency.SNX: 18, +} + DEFAULT_RPC_ENDPOINTS = DATA_DIR / "rpc_endpoints.yaml" NEW_VAULT_ABI_PATH = ABI_DATA_DIR / "socket_superbridge_vault.json" From 1981c8d75ea89638c03e223db24097d84fadf30e Mon Sep 17 00:00:00 2001 From: zarathustra Date: Tue, 19 Aug 2025 18:59:41 +0200 Subject: [PATCH 086/101] fix: to_base_units and from_base_units --- derive_client/utils/w3.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/derive_client/utils/w3.py b/derive_client/utils/w3.py index 49e16de9..588ded7d 100644 --- a/derive_client/utils/w3.py +++ b/derive_client/utils/w3.py @@ -11,8 +11,8 @@ from web3 import Web3 from web3.providers.rpc import HTTPProvider -from derive_client.constants import DEFAULT_RPC_ENDPOINTS, TOKEN_DECIMALS -from derive_client.data_types import ChainID, Currency, RPCEndpoints, UnderlyingCurrency +from derive_client.constants import DEFAULT_RPC_ENDPOINTS, CURRENCY_DECIMALS +from derive_client.data_types import ChainID, Currency, RPCEndpoints from derive_client.exceptions import NoAvailableRPC from derive_client.utils.logger import get_logger @@ -152,4 +152,10 @@ def get_w3_connection( def to_base_units(token_amount: float, currency: Currency) -> int: """Convert a human-readable token amount to base units using the currency's decimals.""" - return int(token_amount * 10 ** TOKEN_DECIMALS[UnderlyingCurrency[currency.name.upper()]]) + return int(token_amount * 10 ** CURRENCY_DECIMALS[currency]) + + +def from_base_units(amount: int, currency: Currency) -> float: + """Convert base units to human-readable amount using the currency's decimals.""" + + return amount / 10 ** CURRENCY_DECIMALS[currency] From 1a430a726d469ba6925bd5d19a4da1e77058f6d3 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Tue, 19 Aug 2025 19:01:09 +0200 Subject: [PATCH 087/101] feat: ETH_DEPOSIT_WRAPPER --- derive_client/constants.py | 1 + 1 file changed, 1 insertion(+) diff --git a/derive_client/constants.py b/derive_client/constants.py index 4131b670..b6bf1d91 100644 --- a/derive_client/constants.py +++ b/derive_client/constants.py @@ -184,6 +184,7 @@ class EnvConfig(BaseModel, frozen=True): L2_STANDARD_BRIDGE_PROXY = "0x4200000000000000000000000000000000000010" L2_CROSS_DOMAIN_MESSENGER_PROXY = "0x4200000000000000000000000000000000000007" WITHDRAW_WRAPPER_V2 = "0xea8E683D8C46ff05B871822a00461995F93df800" +ETH_DEPOSIT_WRAPPER = "0x46e75B6983126896227a5717f2484efb04A0c151" BASE_DEPOSIT_WRAPPER = "0x9628bba16db41ea7fe1fd84f9ce53bc27c63f59b" ARBITRUM_DEPOSIT_WRAPPER = "0x076BB6117750e80AD570D98891B68da86C203A88" OPTIMISM_DEPOSIT_WRAPPER = "0xC65005131Cfdf06622b99E8E17f72Cf694b586cC" From f94cb3b8b9af49eba6df0d47273a087a545d367c Mon Sep 17 00:00:00 2001 From: zarathustra Date: Tue, 19 Aug 2025 19:02:13 +0200 Subject: [PATCH 088/101] feat: feat .value field and convenience properties on PreparedBridgeTx --- derive_client/data_types/models.py | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/derive_client/data_types/models.py b/derive_client/data_types/models.py index 7c818968..03cffea7 100644 --- a/derive_client/data_types/models.py +++ b/derive_client/data_types/models.py @@ -11,6 +11,8 @@ from pydantic import BaseModel, ConfigDict, Field, GetCoreSchemaHandler, GetJsonSchemaHandler, HttpUrl, RootModel from pydantic.dataclasses import dataclass from pydantic_core import core_schema +from rich.table import Table +from rich.console import RenderableType from web3 import AsyncWeb3, Web3 from web3.contract import AsyncContract from web3.contract.async_contract import AsyncContractEvent @@ -259,17 +261,26 @@ class BridgeTxDetails: @property def tx_hash(self) -> str: """Pre-computed transaction hash.""" - return self.signed_tx.hash.to_0x_hex + return self.signed_tx.hash.to_0x_hex() @property - def nonce(self) -> str: + def nonce(self) -> int: """Transaction nonce.""" return self.tx["nonce"] + @property + def gas(self) -> int: + return self.tx["gas"] + + @property + def max_fee_per_gas(self) -> Wei: + return self.tx["maxFeePerGas"] + @dataclass class PreparedBridgeTx: amount: int + value: int currency: Currency source_chain: ChainID target_chain: ChainID @@ -282,10 +293,22 @@ def tx_hash(self) -> str: return self.tx_details.tx_hash @property - def nonce(self) -> str: + def nonce(self) -> int: """Transaction nonce.""" return self.tx_details.nonce + @property + def gas(self) -> int: + return self.tx_details.gas + + @property + def max_fee_per_gas(self) -> Wei: + return self.tx_details.max_fee_per_gas + + @property + def max_total_fee(self) -> Wei: + return self.gas * self.max_fee_per_gas + @dataclass(config=ConfigDict(validate_assignment=True)) class TxResult: From 788cde0f7887b4e21471c6bc7ef2d52302aee89d Mon Sep 17 00:00:00 2001 From: zarathustra Date: Tue, 19 Aug 2025 19:02:54 +0200 Subject: [PATCH 089/101] fix: set both amount and value field on PreparedBridgeTx --- derive_client/_bridge/client.py | 23 +++++++++++------- derive_client/_bridge/standard_bridge.py | 31 +++++++++++------------- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index 269a834d..c7091374 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -27,6 +27,7 @@ DEPOSIT_HOOK_ABI_PATH, DERIVE_ABI_PATH, DERIVE_L2_ABI_PATH, + ETH_DEPOSIT_WRAPPER, ERC20_ABI_PATH, LIGHT_ACCOUNT_ABI_PATH, LYRA_OFT_WITHDRAW_WRAPPER_ABI_PATH, @@ -78,7 +79,7 @@ make_filter_params, send_tx, sign_tx, - wait_for_event, + wait_for_bridge_event, wait_for_tx_finality, ) @@ -172,8 +173,10 @@ def get_deposit_helper(self, chain_id: ChainID) -> AsyncContract: address = OPTIMISM_DEPOSIT_WRAPPER case ChainID.BASE: address = BASE_DEPOSIT_WRAPPER + case ChainID.ETH: + address = ETH_DEPOSIT_WRAPPER case _: - raise ValueError(f"Deposit helper not supported on : {chain_id.name}") + raise ValueError(f"Deposit helper not supported on: {chain_id}") abi = json.loads(DEPOSIT_HELPER_ABI_PATH.read_text()) return get_contract(w3=self.w3s[chain_id], address=address, abi=abi) @@ -270,6 +273,7 @@ def _resolve_socket_route( async def _prepare_tx( self, + amount: int, func: AsyncContractFunction, value: int, context: BridgeContext, @@ -288,7 +292,8 @@ async def _prepare_tx( ) prepared_tx = PreparedBridgeTx( - amount=value, + amount=amount, + value=value, currency=context.currency, source_chain=context.source_chain, target_chain=context.target_chain, @@ -389,7 +394,7 @@ async def _prepare_socket_deposit(self, amount: int, context: BridgeContext) -> func, fees_func = self._prepare_old_style_deposit(token_data, amount, context) fees = await fees_func.call() - prepared_tx = await self._prepare_tx(func=func, value=fees + 1, context=context) + prepared_tx = await self._prepare_tx(amount=amount, func=func, value=fees + 1, context=context) return prepared_tx @@ -418,7 +423,7 @@ async def _prepare_socket_withdrawal(self, amount: int, context: BridgeContext) dest=[context.source_token.address, self.withdraw_wrapper.address], func=[approve_data, bridge_data], ) - prepared_tx = await self._prepare_tx(func=func, value=0, context=context) + prepared_tx = await self._prepare_tx(amount=amount, func=func, value=0, context=context) return prepared_tx @@ -456,7 +461,7 @@ async def _prepare_layerzero_deposit(self, amount: int, context: BridgeContext) refund_address = self.owner func = context.source_token.functions.send(send_params, fees, refund_address) - prepared_tx = await self._prepare_tx(func=func, value=native_fee, context=context) + prepared_tx = await self._prepare_tx(amount=amount, func=func, value=native_fee, context=context) return prepared_tx @@ -486,7 +491,7 @@ async def _prepare_layerzero_withdrawal(self, amount: int, context: BridgeContex dest=[context.source_token.address, withdraw_wrapper.address], func=[approve_data, bridge_data], ) - prepared_tx = await self._prepare_tx(func=func, value=0, context=context) + prepared_tx = await self._prepare_tx(amount=amount, func=func, value=0, context=context) return prepared_tx @@ -577,7 +582,7 @@ async def _fetch_lz_event_log(self, tx_result: BridgeTxResult, context: BridgeCo f"🔍 Listening for OFTReceived on [{tx_result.target_chain.name}] at {context.target_event.address}" ) - return await wait_for_event(context.target_w3, filter_params, logger=self.logger) + return await wait_for_bridge_event(context.target_w3, filter_params, logger=self.logger) async def _fetch_socket_event_log(self, tx_result: BridgeTxResult, context: BridgeContext) -> LogReceipt: @@ -601,7 +606,7 @@ def matching_message_id(log: AttributeDict) -> bool: f"🔍 Listening for ExecutionSuccess on [{tx_result.target_chain.name}] at {context.target_event.address}" ) - return await wait_for_event(context.target_w3, filter_params, condition=matching_message_id, logger=self.logger) + return await wait_for_bridge_event(context.target_w3, filter_params, condition=matching_message_id, logger=self.logger) def _prepare_new_style_deposit( self, diff --git a/derive_client/_bridge/standard_bridge.py b/derive_client/_bridge/standard_bridge.py index 0a1a5c90..f41cd832 100644 --- a/derive_client/_bridge/standard_bridge.py +++ b/derive_client/_bridge/standard_bridge.py @@ -42,7 +42,7 @@ make_filter_params, send_tx, sign_tx, - wait_for_event, + wait_for_bridge_event, wait_for_tx_finality, ) @@ -88,27 +88,25 @@ def __init__(self, account: Account, logger: Logger): self.l2_messenger_proxy = _load_l2_cross_domain_messenger_proxy(self.w3s[ChainID.DERIVE]) @future_safe - async def prepare_tx( + async def prepare_eth_tx( self, - token_amount: float, - currency: Currency, + eth_amount: float, to: Address, source_chain: ChainID, target_chain: ChainID, ) -> IOResult[PreparedBridgeTx, Exception]: + currency = Currency.ETH + if ( - currency is not Currency.ETH - or source_chain is not ChainID.ETH + source_chain is not ChainID.ETH or target_chain is not ChainID.DERIVE or to != self.account.address ): raise NotImplementedError("Only ETH transfers from Ethereum to Derive EOA are currently supported.") - amount: int = to_base_units(token_amount=token_amount, currency=currency) - prepared_tx = await self._prepare_tx( - amount=amount, currency=currency, to=to, source_chain=source_chain, target_chain=target_chain - ) + value: int = to_base_units(token_amount=eth_amount, currency=currency) + prepared_tx = await self._prepare_eth_tx(value=value, to=to, source_chain=source_chain, target_chain=target_chain) return prepared_tx @@ -136,10 +134,9 @@ async def poll_bridge_progress(self, tx_result: BridgeTxResult) -> IOResult[Brid return tx_result - async def _prepare_tx( + async def _prepare_eth_tx( self, - amount: int, - currency: Currency, + value: int, to: Address, source_chain: ChainID, target_chain: ChainID, @@ -153,7 +150,6 @@ async def _prepare_tx( _minGasLimit=MSG_GAS_LIMIT, _extraData=b"", ) - value = amount tx = await build_standard_transaction(func=func, account=self.account, w3=w3, value=value, logger=self.logger) @@ -173,7 +169,8 @@ async def _prepare_tx( ) prepared_tx = PreparedBridgeTx( - amount=amount, + amount=0, + value=value, currency=Currency.ETH, source_chain=source_chain, target_chain=target_chain, @@ -293,8 +290,8 @@ async def _fetch_standard_event_log(self, tx_result: BridgeTxResult) -> LogRecei self.logger.info(f"🔍 Listening for msgHash on [{tx_result.target_chain.name}] at {target_event.address}") - relayed_task = asyncio.create_task(wait_for_event(target_w3, filter_params, logger=self.logger)) - failed_task = asyncio.create_task(wait_for_event(target_w3, failed_filter_params, logger=self.logger)) + relayed_task = asyncio.create_task(wait_for_bridge_event(target_w3, filter_params, logger=self.logger)) + failed_task = asyncio.create_task(wait_for_bridge_event(target_w3, failed_filter_params, logger=self.logger)) done, pending = await asyncio.wait([relayed_task, failed_task], return_when=asyncio.FIRST_COMPLETED) for task in pending: From 06e2341502773b949a6908e9b82169f8efa2412c Mon Sep 17 00:00:00 2001 From: zarathustra Date: Tue, 19 Aug 2025 19:14:10 +0200 Subject: [PATCH 090/101] refactor: rename token_amount to human_amount --- derive_client/_bridge/client.py | 8 ++++---- derive_client/_bridge/standard_bridge.py | 4 ++-- derive_client/clients/async_client.py | 18 +++++++++--------- derive_client/clients/http_client.py | 12 ++++++------ derive_client/data_types/models.py | 2 -- derive_client/utils/__init__.py | 3 ++- derive_client/utils/w3.py | 4 ++-- 7 files changed, 25 insertions(+), 26 deletions(-) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index c7091374..9029de27 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -306,7 +306,7 @@ async def _prepare_tx( @future_safe async def prepare_deposit( self, - token_amount: float, + human_amount: float, currency: Currency, chain_id: ChainID, ) -> IOResult[PreparedBridgeTx, Exception]: @@ -314,7 +314,7 @@ async def prepare_deposit( if currency is Currency.ETH: raise NotImplementedError("ETH deposits are not implemented.") - amount: int = to_base_units(token_amount=token_amount, currency=currency) + amount: int = to_base_units(human_amount=human_amount, currency=currency) await self.verify_owner() direction = Direction.DEPOSIT @@ -331,7 +331,7 @@ async def prepare_deposit( @future_safe async def prepare_withdrawal( self, - token_amount: float, + human_amount: float, currency: Currency, chain_id: ChainID, ) -> IOResult[PreparedBridgeTx, Exception]: @@ -339,7 +339,7 @@ async def prepare_withdrawal( if currency is Currency.ETH: raise NotImplementedError("ETH withdrawals are not implemented.") - amount: int = to_base_units(token_amount=token_amount, currency=currency) + amount: int = to_base_units(human_amount=human_amount, currency=currency) await self.verify_owner() direction = Direction.WITHDRAW diff --git a/derive_client/_bridge/standard_bridge.py b/derive_client/_bridge/standard_bridge.py index f41cd832..3b686836 100644 --- a/derive_client/_bridge/standard_bridge.py +++ b/derive_client/_bridge/standard_bridge.py @@ -90,7 +90,7 @@ def __init__(self, account: Account, logger: Logger): @future_safe async def prepare_eth_tx( self, - eth_amount: float, + human_amount: float, to: Address, source_chain: ChainID, target_chain: ChainID, @@ -105,7 +105,7 @@ async def prepare_eth_tx( ): raise NotImplementedError("Only ETH transfers from Ethereum to Derive EOA are currently supported.") - value: int = to_base_units(token_amount=eth_amount, currency=currency) + value: int = to_base_units(human_amount=human_amount, currency=currency) prepared_tx = await self._prepare_eth_tx(value=value, to=to, source_chain=source_chain, target_chain=target_chain) return prepared_tx diff --git a/derive_client/clients/async_client.py b/derive_client/clients/async_client.py index 6de0a838..4d08c33b 100644 --- a/derive_client/clients/async_client.py +++ b/derive_client/clients/async_client.py @@ -74,7 +74,7 @@ def _standard_bridge(self) -> StandardBridge: async def prepare_standard_tx( self, - token_amount: float, + human_amount: float, currency: Currency, to: Address, source_chain: ChainID, @@ -87,7 +87,7 @@ async def prepare_standard_tx( Review the returned PreparedBridgeTx before calling submit_bridge_tx(). Args: - token_amount: Amount in token units (e.g., 1.5 USDC, 0.1 ETH) + human_amount: Amount in token units (e.g., 1.5 USDC, 0.1 ETH) currency: Currency enum value describing the token to bridge to: Destination address on the target chain source_chain: ChainID for the source chain @@ -106,7 +106,7 @@ async def prepare_standard_tx( """ result = await self._standard_bridge.prepare_tx( - token_amount=token_amount, + human_amount=human_amount, currency=currency, to=to, source_chain=source_chain, @@ -117,7 +117,7 @@ async def prepare_standard_tx( async def prepare_deposit_to_derive( self, - token_amount: float, + human_amount: float, currency: Currency, chain_id: ChainID, ) -> PreparedBridgeTx: @@ -128,7 +128,7 @@ async def prepare_deposit_to_derive( Review the returned PreparedBridgeTx before calling submit_bridge_tx(). Args: - amount: Amount in token units (e.g., 1.5 USDC, 0.1 ETH) + human_amount: Amount in token units (e.g., 1.5 USDC, 0.1 ETH) currency: Token to bridge chain_id: Source chain to bridge from @@ -150,12 +150,12 @@ async def prepare_deposit_to_derive( "For gas funding of the owner (EOA) use `prepare_standard_tx`." ) - result = await self._bridge.prepare_deposit(token_amount=token_amount, currency=currency, chain_id=chain_id) + result = await self._bridge.prepare_deposit(human_amount=human_amount, currency=currency, chain_id=chain_id) return unwrap_or_raise(result) async def prepare_withdrawal_from_derive( self, - token_amount: float, + human_amount: float, currency: Currency, chain_id: ChainID, ) -> PreparedBridgeTx: @@ -166,7 +166,7 @@ async def prepare_withdrawal_from_derive( Review the returned PreparedBridgeTx before calling submit_bridge_tx(). Args: - amount: Amount in token units (e.g., 1.5 USDC, 0.1 ETH) + human_amount: Amount in token units (e.g., 1.5 USDC, 0.1 ETH) currency: Token to bridge chain_id: Target chain to bridge to @@ -182,7 +182,7 @@ async def prepare_withdrawal_from_derive( - Submit with submit_bridge_tx() when ready """ - result = await self._bridge.prepare_withdrawal(token_amount=token_amount, currency=currency, chain_id=chain_id) + result = await self._bridge.prepare_withdrawal(human_amount=human_amount, currency=currency, chain_id=chain_id) return unwrap_or_raise(result) async def submit_bridge_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResult: diff --git a/derive_client/clients/http_client.py b/derive_client/clients/http_client.py index 2b579ce6..387e6be1 100644 --- a/derive_client/clients/http_client.py +++ b/derive_client/clients/http_client.py @@ -46,7 +46,7 @@ def _async_client(self) -> AsyncClient: def prepare_standard_tx( self, - token_amount: float, + human_amount: float, currency: Currency, to: Address, source_chain: ChainID, @@ -55,7 +55,7 @@ def prepare_standard_tx( """Thin sync wrapper around AsyncClient.prepare_standard_tx.""" coroutine = self._async_client.prepare_standard_tx( - token_amount=token_amount, + human_amount=human_amount, currency=currency, to=to, source_chain=source_chain, @@ -66,14 +66,14 @@ def prepare_standard_tx( def prepare_deposit_to_derive( self, - token_amount: float, + human_amount: float, currency: Currency, chain_id: ChainID, ) -> PreparedBridgeTx: """Thin sync wrapper around AsyncClient.prepare_deposit_to_derive.""" coroutine = self._async_client.prepare_deposit_to_derive( - token_amount=token_amount, + human_amount=human_amount, currency=currency, chain_id=chain_id, ) @@ -81,14 +81,14 @@ def prepare_deposit_to_derive( def prepare_withdrawal_from_derive( self, - token_amount: float, + human_amount: float, currency: Currency, chain_id: ChainID, ) -> PreparedBridgeTx: """Thin sync wrapper around AsyncClient.prepare_withdrawal_from_derive.""" coroutine = self._async_client.prepare_withdrawal_from_derive( - token_amount=token_amount, + human_amount=human_amount, currency=currency, chain_id=chain_id, ) diff --git a/derive_client/data_types/models.py b/derive_client/data_types/models.py index 03cffea7..9ff32387 100644 --- a/derive_client/data_types/models.py +++ b/derive_client/data_types/models.py @@ -11,8 +11,6 @@ from pydantic import BaseModel, ConfigDict, Field, GetCoreSchemaHandler, GetJsonSchemaHandler, HttpUrl, RootModel from pydantic.dataclasses import dataclass from pydantic_core import core_schema -from rich.table import Table -from rich.console import RenderableType from web3 import AsyncWeb3, Web3 from web3.contract import AsyncContract from web3.contract.async_contract import AsyncContractEvent diff --git a/derive_client/utils/__init__.py b/derive_client/utils/__init__.py index c3b72e40..d025258f 100644 --- a/derive_client/utils/__init__.py +++ b/derive_client/utils/__init__.py @@ -5,7 +5,7 @@ from .prod_addresses import get_prod_derive_addresses from .retry import exp_backoff_retry, get_retry_session, wait_until from .unwrap import unwrap_or_raise -from .w3 import get_w3_connection, load_rpc_endpoints, to_base_units +from .w3 import get_w3_connection, load_rpc_endpoints, to_base_units, from_base_units __all__ = [ "get_logger", @@ -16,6 +16,7 @@ "get_w3_connection", "load_rpc_endpoints", "to_base_units", + "from_base_units", "download_prod_address_abis", "unwrap_or_raise", ] diff --git a/derive_client/utils/w3.py b/derive_client/utils/w3.py index 588ded7d..012e9ad5 100644 --- a/derive_client/utils/w3.py +++ b/derive_client/utils/w3.py @@ -149,10 +149,10 @@ def get_w3_connection( return w3 -def to_base_units(token_amount: float, currency: Currency) -> int: +def to_base_units(human_amount: float, currency: Currency) -> int: """Convert a human-readable token amount to base units using the currency's decimals.""" - return int(token_amount * 10 ** CURRENCY_DECIMALS[currency]) + return int(human_amount * 10 ** CURRENCY_DECIMALS[currency]) def from_base_units(amount: int, currency: Currency) -> float: From 752b0ed5257e87732c6b8abde9e97e8fce9b23dc Mon Sep 17 00:00:00 2001 From: zarathustra Date: Tue, 19 Aug 2025 19:18:15 +0200 Subject: [PATCH 091/101] feat: display prepared tx and ask for confirmation before execution in CLI tool --- derive_client/cli.py | 55 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/derive_client/cli.py b/derive_client/cli.py index 23691420..d3c6b618 100644 --- a/derive_client/cli.py +++ b/derive_client/cli.py @@ -2,6 +2,7 @@ Cli module in order to allow interaction. """ +import math import os from pathlib import Path from textwrap import dedent @@ -10,6 +11,7 @@ import rich_click as click from dotenv import load_dotenv from rich import print +from rich.table import Table from derive_client.analyser import PortfolioAnalyser from derive_client.data_types import ( @@ -21,17 +23,48 @@ OrderSide, OrderStatus, OrderType, + PreparedBridgeTx, SubaccountType, TxStatus, UnderlyingCurrency, ) from derive_client.derive import DeriveClient -from derive_client.utils import get_logger +from derive_client.utils import from_base_units, get_logger click.rich_click.USE_RICH_MARKUP = True pd.set_option("display.precision", 2) +def fmt_sig_up_to(x: float, sig: int = 4) -> str: + """Format x to up to `sig` significant digits, preserving all necessary decimals.""" + + if x == 0: + return "0" + + order = math.floor(math.log10(abs(x))) + decimals = max(sig - order - 1, 0) + formatted = f"{x:.{decimals}f}" + return formatted.rstrip("0").rstrip(".") + + +def rich_prepared_tx(prepared_tx: PreparedBridgeTx): + + table = Table(title="Prepared Bridge Transaction", show_header=False, box=None) + if prepared_tx.amount > 0: + human_amount = from_base_units(amount=prepared_tx.amount, currency=prepared_tx.currency) + table.add_row("Amount", f"{human_amount} {prepared_tx.currency.name} (base units: {prepared_tx.amount})") + table.add_row("Value", f"{fmt_sig_up_to(prepared_tx.value / 1e18)} ETH (base units: {prepared_tx.value})") + table.add_row("Source chain", prepared_tx.source_chain.name) + table.add_row("Target chain", prepared_tx.target_chain.name) + table.add_row("Bridge type", prepared_tx.bridge_type.name) + table.add_row("Tx hash", prepared_tx.tx_hash) + table.add_row("Gas limit", str(prepared_tx.gas)) + table.add_row("Max fee/gas", f"{fmt_sig_up_to(prepared_tx.max_fee_per_gas / 1e9)} gwei") + table.add_row("Max total fee", f"{fmt_sig_up_to(prepared_tx.max_total_fee / 1e9)} gwei") + + return table + + def set_logger(ctx, level): """Set the logger.""" if not hasattr(ctx, "logger"): @@ -140,7 +173,7 @@ def bridge(): "-a", type=float, required=True, - help="The amount to deposit in ETH (will be converted to Wei).", + help="The amount to deposit in human units of the selected token (converted to base units internally).", ) @click.pass_context def deposit(ctx, chain_id, currency, amount): @@ -156,7 +189,13 @@ def deposit(ctx, chain_id, currency, amount): client: DeriveClient = ctx.obj["client"] - prepared_tx = client.prepare_deposit_to_derive(chain_id=chain_id, currency=currency, token_amount=amount) + prepared_tx = client.prepare_deposit_to_derive(chain_id=chain_id, currency=currency, human_amount=amount) + + print(rich_prepared_tx(prepared_tx)) + if not click.confirm("Do you want to submit this transaction?", default=False): + print("[yellow]Aborted by user.[/yellow]") + return + tx_result = client.submit_bridge_tx(prepared_tx=prepared_tx) bridge_tx_result = client.poll_bridge_progress(tx_result=tx_result) @@ -191,7 +230,7 @@ def deposit(ctx, chain_id, currency, amount): "-a", type=float, required=True, - help="The amount to deposit in ETH (will be converted to Wei).", + help="The amount to withdraw in human units of the selected token (converted to base units internally).", ) @click.pass_context def withdraw(ctx, chain_id, currency, amount): @@ -207,7 +246,13 @@ def withdraw(ctx, chain_id, currency, amount): client: DeriveClient = ctx.obj["client"] - prepared_tx = client.prepare_withdrawal_from_derive(chain_id=chain_id, currency=currency, token_amount=amount) + prepared_tx = client.prepare_withdrawal_from_derive(chain_id=chain_id, currency=currency, human_amount=amount) + + print(rich_prepared_tx(prepared_tx)) + if not click.confirm("Do you want to submit this transaction?", default=False): + print("[yellow]Aborted by user.[/yellow]") + return + tx_result = client.submit_bridge_tx(prepared_tx=prepared_tx) bridge_tx_result = client.poll_bridge_progress(tx_result=tx_result) From 11653a43893f35f846dd3c1fce0c5ec112f61a2b Mon Sep 17 00:00:00 2001 From: zarathustra Date: Tue, 19 Aug 2025 19:35:57 +0200 Subject: [PATCH 092/101] feat: verification of onchain decimals --- derive_client/_bridge/client.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index 9029de27..1a24640b 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -23,6 +23,7 @@ CONFIGS, CONTROLLER_ABI_PATH, CONTROLLER_V0_ABI_PATH, + CURRENCY_DECIMALS, DEPOSIT_HELPER_ABI_PATH, DEPOSIT_HOOK_ABI_PATH, DERIVE_ABI_PATH, @@ -279,6 +280,14 @@ async def _prepare_tx( context: BridgeContext, ) -> PreparedBridgeTx: + onchain_decimals: int = await context.source_token.functions.decimals().call() + if onchain_decimals != (expected_decimals := CURRENCY_DECIMALS[context.currency]): + raise RuntimeError( + f"Decimal mismatch for {context.currency.name} on {context.source_chain.name}: " + f"expected {expected_decimals}, got {onchain_decimals}" + ) + + w3 = context.source_w3 tx = await build_standard_transaction(func=func, account=self.account, w3=w3, value=value, logger=self.logger) signed_tx = sign_tx(w3=context.source_w3, tx=tx, private_key=self.private_key) From 8c4f131e5e8eec33949b26d1cb9f54258f27143d Mon Sep 17 00:00:00 2001 From: zarathustra Date: Tue, 19 Aug 2025 19:39:22 +0200 Subject: [PATCH 093/101] chore: make fmt lint --- derive_client/_bridge/client.py | 16 ++++++++++++---- derive_client/_bridge/standard_bridge.py | 13 +++++++------ derive_client/constants.py | 2 +- derive_client/utils/__init__.py | 2 +- derive_client/utils/w3.py | 2 +- 5 files changed, 22 insertions(+), 13 deletions(-) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index 1a24640b..679a6e5b 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -28,8 +28,8 @@ DEPOSIT_HOOK_ABI_PATH, DERIVE_ABI_PATH, DERIVE_L2_ABI_PATH, - ETH_DEPOSIT_WRAPPER, ERC20_ABI_PATH, + ETH_DEPOSIT_WRAPPER, LIGHT_ACCOUNT_ABI_PATH, LYRA_OFT_WITHDRAW_WRAPPER_ABI_PATH, LYRA_OFT_WITHDRAW_WRAPPER_ADDRESS, @@ -287,7 +287,6 @@ async def _prepare_tx( f"expected {expected_decimals}, got {onchain_decimals}" ) - w3 = context.source_w3 tx = await build_standard_transaction(func=func, account=self.account, w3=w3, value=value, logger=self.logger) signed_tx = sign_tx(w3=context.source_w3, tx=tx, private_key=self.private_key) @@ -591,7 +590,11 @@ async def _fetch_lz_event_log(self, tx_result: BridgeTxResult, context: BridgeCo f"🔍 Listening for OFTReceived on [{tx_result.target_chain.name}] at {context.target_event.address}" ) - return await wait_for_bridge_event(context.target_w3, filter_params, logger=self.logger) + return await wait_for_bridge_event( + w3=context.target_w3, + filter_params=filter_params, + logger=self.logger, + ) async def _fetch_socket_event_log(self, tx_result: BridgeTxResult, context: BridgeContext) -> LogReceipt: @@ -615,7 +618,12 @@ def matching_message_id(log: AttributeDict) -> bool: f"🔍 Listening for ExecutionSuccess on [{tx_result.target_chain.name}] at {context.target_event.address}" ) - return await wait_for_bridge_event(context.target_w3, filter_params, condition=matching_message_id, logger=self.logger) + return await wait_for_bridge_event( + w3=context.target_w3, + filter_params=filter_params, + condition=matching_message_id, + logger=self.logger, + ) def _prepare_new_style_deposit( self, diff --git a/derive_client/_bridge/standard_bridge.py b/derive_client/_bridge/standard_bridge.py index 3b686836..d626229e 100644 --- a/derive_client/_bridge/standard_bridge.py +++ b/derive_client/_bridge/standard_bridge.py @@ -98,15 +98,16 @@ async def prepare_eth_tx( currency = Currency.ETH - if ( - source_chain is not ChainID.ETH - or target_chain is not ChainID.DERIVE - or to != self.account.address - ): + if source_chain is not ChainID.ETH or target_chain is not ChainID.DERIVE or to != self.account.address: raise NotImplementedError("Only ETH transfers from Ethereum to Derive EOA are currently supported.") value: int = to_base_units(human_amount=human_amount, currency=currency) - prepared_tx = await self._prepare_eth_tx(value=value, to=to, source_chain=source_chain, target_chain=target_chain) + prepared_tx = await self._prepare_eth_tx( + value=value, + to=to, + source_chain=source_chain, + target_chain=target_chain, + ) return prepared_tx diff --git a/derive_client/constants.py b/derive_client/constants.py index b6bf1d91..26ee9501 100644 --- a/derive_client/constants.py +++ b/derive_client/constants.py @@ -6,7 +6,7 @@ from pydantic import BaseModel -from derive_client.data_types import Environment, UnderlyingCurrency, Currency +from derive_client.data_types import Currency, Environment, UnderlyingCurrency class ContractAddresses(BaseModel, frozen=True): diff --git a/derive_client/utils/__init__.py b/derive_client/utils/__init__.py index d025258f..c3fc9e41 100644 --- a/derive_client/utils/__init__.py +++ b/derive_client/utils/__init__.py @@ -5,7 +5,7 @@ from .prod_addresses import get_prod_derive_addresses from .retry import exp_backoff_retry, get_retry_session, wait_until from .unwrap import unwrap_or_raise -from .w3 import get_w3_connection, load_rpc_endpoints, to_base_units, from_base_units +from .w3 import from_base_units, get_w3_connection, load_rpc_endpoints, to_base_units __all__ = [ "get_logger", diff --git a/derive_client/utils/w3.py b/derive_client/utils/w3.py index 012e9ad5..c5cad7ea 100644 --- a/derive_client/utils/w3.py +++ b/derive_client/utils/w3.py @@ -11,7 +11,7 @@ from web3 import Web3 from web3.providers.rpc import HTTPProvider -from derive_client.constants import DEFAULT_RPC_ENDPOINTS, CURRENCY_DECIMALS +from derive_client.constants import CURRENCY_DECIMALS, DEFAULT_RPC_ENDPOINTS from derive_client.data_types import ChainID, Currency, RPCEndpoints from derive_client.exceptions import NoAvailableRPC from derive_client.utils.logger import get_logger From acc783b41aa6f2290a8148fa5ad251e9609acbb0 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Wed, 20 Aug 2025 11:28:10 +0200 Subject: [PATCH 094/101] refactor: BridgeTxResult --- derive_client/_bridge/client.py | 7 +------ derive_client/_bridge/standard_bridge.py | 7 +------ derive_client/data_types/models.py | 23 +++++++++++++++++------ 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index 679a6e5b..c61fbbff 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -515,14 +515,9 @@ async def _send_bridge_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResult source_tx = TxResult(tx_hash=tx_hash) tx_result = BridgeTxResult( - amount=prepared_tx.amount, - currency=prepared_tx.currency, - bridge_type=prepared_tx.bridge_type, - source_chain=prepared_tx.source_chain, - target_chain=prepared_tx.target_chain, + prepared_tx=prepared_tx, source_tx=source_tx, target_from_block=target_from_block, - tx_details=prepared_tx.tx_details, ) return tx_result diff --git a/derive_client/_bridge/standard_bridge.py b/derive_client/_bridge/standard_bridge.py index d626229e..a43b052e 100644 --- a/derive_client/_bridge/standard_bridge.py +++ b/derive_client/_bridge/standard_bridge.py @@ -194,14 +194,9 @@ async def _send_bridge_tx(self, prepared_tx: PreparedBridgeTx) -> BridgeTxResult source_tx = TxResult(tx_hash=tx_hash) tx_result = BridgeTxResult( - amount=prepared_tx.amount, - currency=prepared_tx.currency, - bridge_type=prepared_tx.bridge_type, - source_chain=prepared_tx.source_chain, - target_chain=prepared_tx.target_chain, + prepared_tx=prepared_tx, source_tx=source_tx, target_from_block=target_from_block, - tx_details=prepared_tx.tx_details, ) return tx_result diff --git a/derive_client/data_types/models.py b/derive_client/data_types/models.py index 9ff32387..07fc8067 100644 --- a/derive_client/data_types/models.py +++ b/derive_client/data_types/models.py @@ -322,13 +322,8 @@ def status(self) -> TxStatus: @dataclass(config=ConfigDict(validate_assignment=True)) class BridgeTxResult: - amount: int - currency: Currency - bridge_type: BridgeType - source_chain: ChainID - target_chain: ChainID + prepared_tx: PreparedBridgeTx source_tx: TxResult - tx_details: BridgeTxDetails target_from_block: int event_id: str | None = None target_tx: TxResult | None = None @@ -339,6 +334,22 @@ def status(self) -> TxStatus: return self.source_tx.status return self.target_tx.status if self.target_tx is not None else TxStatus.PENDING + @property + def currency(self) -> Currency: + return self.prepared_tx.currency + + @property + def source_chain(self) -> ChainID: + return self.prepared_tx.source_chain + + @property + def target_chain(self) -> ChainID: + return self.prepared_tx.target_chain + + @property + def bridge_type(self) -> BridgeType: + return self.prepared_tx.bridge_type + class DepositResult(BaseModel): status: DeriveTxStatus # should be "REQUESTED" From 3a5fda45280734e5722fee8fba7f820f33c37372 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Wed, 20 Aug 2025 12:48:23 +0200 Subject: [PATCH 095/101] feat: check estimated fee and compare to amount on socket withdrawal --- derive_client/_bridge/client.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index c61fbbff..499e8623 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -413,6 +413,16 @@ async def _prepare_socket_withdrawal(self, amount: int, context: BridgeContext) await ensure_token_balance(context.source_token, self.wallet, amount) await self._check_bridge_funds(token_data, connector, amount) + # Get estimated fee in token for a withdrawal + fee = await self.withdraw_wrapper.functions.getFeeInToken( + token=token_data.MintableToken, + controller=token_data.Controller, + connector=token_data.connectors[context.target_chain][TARGET_SPEED], + gasLimit=MSG_GAS_LIMIT, + ).call() + if amount < fee: + raise DrvWithdrawAmountBelowFee(f"Withdraw amount < fee: {amount} < {fee} ({(fee / amount * 100):.2f}%)") + kwargs = { "token": context.source_token.address, "amount": amount, From bf52840a12e3f86b291462a35c5921b9a43b5f8f Mon Sep 17 00:00:00 2001 From: zarathustra Date: Wed, 20 Aug 2025 14:02:38 +0200 Subject: [PATCH 096/101] fix: check fee on socket withdrawal --- derive_client/_bridge/client.py | 25 +++++++++------- derive_client/_bridge/standard_bridge.py | 2 ++ derive_client/clients/base_client.py | 2 +- derive_client/data_types/models.py | 38 ++++++++++++++++++++++++ derive_client/exceptions.py | 4 +++ 5 files changed, 59 insertions(+), 12 deletions(-) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index 499e8623..1ec74c6b 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -277,6 +277,7 @@ async def _prepare_tx( amount: int, func: AsyncContractFunction, value: int, + fee_in_token: int, context: BridgeContext, ) -> PreparedBridgeTx: @@ -301,7 +302,9 @@ async def _prepare_tx( prepared_tx = PreparedBridgeTx( amount=amount, - value=value, + value=0, + fee_value=value, + fee_in_token=fee_in_token, currency=context.currency, source_chain=context.source_chain, target_chain=context.target_chain, @@ -402,7 +405,7 @@ async def _prepare_socket_deposit(self, amount: int, context: BridgeContext) -> func, fees_func = self._prepare_old_style_deposit(token_data, amount, context) fees = await fees_func.call() - prepared_tx = await self._prepare_tx(amount=amount, func=func, value=fees + 1, context=context) + prepared_tx = await self._prepare_tx(amount=amount, func=func, value=fees + 1, fee_in_token=0, context=context) return prepared_tx @@ -414,14 +417,14 @@ async def _prepare_socket_withdrawal(self, amount: int, context: BridgeContext) await self._check_bridge_funds(token_data, connector, amount) # Get estimated fee in token for a withdrawal - fee = await self.withdraw_wrapper.functions.getFeeInToken( + fee_in_token = await self.withdraw_wrapper.functions.getFeeInToken( token=token_data.MintableToken, controller=token_data.Controller, connector=token_data.connectors[context.target_chain][TARGET_SPEED], gasLimit=MSG_GAS_LIMIT, ).call() - if amount < fee: - raise DrvWithdrawAmountBelowFee(f"Withdraw amount < fee: {amount} < {fee} ({(fee / amount * 100):.2f}%)") + if amount < fee_in_token: + raise DrvWithdrawAmountBelowFee(f"Withdraw amount < fee: {amount} < {fee_in_token} ({(fee_in_token / amount * 100):.2f}%)") kwargs = { "token": context.source_token.address, @@ -441,7 +444,7 @@ async def _prepare_socket_withdrawal(self, amount: int, context: BridgeContext) dest=[context.source_token.address, self.withdraw_wrapper.address], func=[approve_data, bridge_data], ) - prepared_tx = await self._prepare_tx(amount=amount, func=func, value=0, context=context) + prepared_tx = await self._prepare_tx(amount=amount, func=func, value=0, fee_in_token=fee_in_token, context=context) return prepared_tx @@ -479,7 +482,7 @@ async def _prepare_layerzero_deposit(self, amount: int, context: BridgeContext) refund_address = self.owner func = context.source_token.functions.send(send_params, fees, refund_address) - prepared_tx = await self._prepare_tx(amount=amount, func=func, value=native_fee, context=context) + prepared_tx = await self._prepare_tx(amount=amount, func=func, value=native_fee, fee_in_token=0, context=context) return prepared_tx @@ -491,9 +494,9 @@ async def _prepare_layerzero_withdrawal(self, amount: int, context: BridgeContex await ensure_token_balance(context.source_token, self.wallet, amount) destEID = LayerZeroChainIDv2[context.target_chain.name] - fee = await withdraw_wrapper.functions.getFeeInToken(context.source_token.address, amount, destEID).call() - if amount < fee: - raise DrvWithdrawAmountBelowFee(f"Withdraw amount < fee: {amount} < {fee} ({(fee / amount * 100):.2f}%)") + fee_in_token = await withdraw_wrapper.functions.getFeeInToken(context.source_token.address, amount, destEID).call() + if amount < fee_in_token: + raise DrvWithdrawAmountBelowFee(f"Withdraw amount < fee: {amount} < {fee_in_token} ({(fee_in_token / amount * 100):.2f}%)") kwargs = { "token": context.source_token.address, @@ -509,7 +512,7 @@ async def _prepare_layerzero_withdrawal(self, amount: int, context: BridgeContex dest=[context.source_token.address, withdraw_wrapper.address], func=[approve_data, bridge_data], ) - prepared_tx = await self._prepare_tx(amount=amount, func=func, value=0, context=context) + prepared_tx = await self._prepare_tx(amount=amount, func=func, value=0, fee_in_token=fee_in_token, context=context) return prepared_tx diff --git a/derive_client/_bridge/standard_bridge.py b/derive_client/_bridge/standard_bridge.py index a43b052e..098a5595 100644 --- a/derive_client/_bridge/standard_bridge.py +++ b/derive_client/_bridge/standard_bridge.py @@ -172,6 +172,8 @@ async def _prepare_eth_tx( prepared_tx = PreparedBridgeTx( amount=0, value=value, + fee_value=0, + fee_in_token=0, currency=Currency.ETH, source_chain=source_chain, target_chain=target_chain, diff --git a/derive_client/clients/base_client.py b/derive_client/clients/base_client.py index c63fce43..d779fd67 100644 --- a/derive_client/clients/base_client.py +++ b/derive_client/clients/base_client.py @@ -121,7 +121,7 @@ def _determine_subaccount_id(self, subaccount_id: int | None) -> int: if subaccount_id is not None and subaccount_id not in subaccount_ids: raise ValueError(f"Provided subaccount {subaccount_id} not among retrieved aubaccounts: {subaccounts!r}") subaccount_id = subaccount_id or subaccount_ids[0] - self.logger.info(f"Selected subaccount_id: {subaccount_id}") + self.logger.debug(f"Selected subaccount_id: {subaccount_id}") return subaccount_id def create_account(self, wallet): diff --git a/derive_client/data_types/models.py b/derive_client/data_types/models.py index 07fc8067..82252019 100644 --- a/derive_client/data_types/models.py +++ b/derive_client/data_types/models.py @@ -16,6 +16,7 @@ from web3.contract.async_contract import AsyncContractEvent from web3.datastructures import AttributeDict +from derive_client.exceptions import TxReceiptMissing from .enums import ( BridgeType, ChainID, @@ -29,6 +30,7 @@ ) + class PAttributeDict(AttributeDict): @classmethod @@ -268,6 +270,7 @@ def nonce(self) -> int: @property def gas(self) -> int: + """Gas limit""" return self.tx["gas"] @property @@ -285,6 +288,25 @@ class PreparedBridgeTx: bridge_type: BridgeType tx_details: BridgeTxDetails + fee_value: int + fee_in_token: int + + def __post_init_post_parse__(self) -> None: + + # rule 1: don't allow both amount (erc20) and value (native) to be non-zero + if self.amount and self.value: + raise ValueError( + f"PreparedBridgeTx: both amount ({self.amount}) and value ({self.value}) are non-zero; " + "use `prepare_erc20_tx` or `prepare_eth_tx` instead." + ) + + # rule 2: don't allow both fee types to be non-zero simultaneously + if self.fee_value and self.fee_in_token: + raise ValueError( + f"PreparedBridgeTx: both fee_value ({self.fee_value}) and fee_in_token ({self.fee_in_token}) are non-zero; " + "fees must be expressed in only one currency." + ) + @property def tx_hash(self) -> str: """Pre-computed transaction hash.""" @@ -350,6 +372,22 @@ def target_chain(self) -> ChainID: def bridge_type(self) -> BridgeType: return self.prepared_tx.bridge_type + @property + def gas_used(self) -> int: + if not self.source_tx.tx_receipt: + raise TxReceiptMissing("Source tx receipt not available") + return self.source_tx.tx_receipt["gasUsed"] + + @property + def effective_gas_price(self) -> Wei: + if not self.source_tx.tx_receipt: + raise TxReceiptMissing("Source tx receipt not available") + return self.source_tx.tx_receipt["effectiveGasPrice"] + + @property + def total_fee(self) -> Wei: + return self.gas_used * self.effective_gas_price + class DepositResult(BaseModel): status: DeriveTxStatus # should be "REQUESTED" diff --git a/derive_client/exceptions.py b/derive_client/exceptions.py index e5be89c0..75826a6e 100644 --- a/derive_client/exceptions.py +++ b/derive_client/exceptions.py @@ -99,6 +99,10 @@ class DrvWithdrawAmountBelowFee(Exception): """Raised when the DRV withdrawal amount is less than the fee required to withdraw.""" +class TxReceiptMissing(Exception): + """Raised when a transaction receipt is required but not available.""" + + class FinalityTimeout(Exception): """Raised when the transaction was mined but did not reach the required finality within the timeout.""" From 6825d5f7a7a49d309f1fc20ae2ee8a08ff37d13e Mon Sep 17 00:00:00 2001 From: zarathustra Date: Wed, 20 Aug 2025 14:14:57 +0200 Subject: [PATCH 097/101] fix: token balance check --- derive_client/_bridge/client.py | 49 +++++++++++++++++++----------- derive_client/_bridge/w3.py | 6 ++-- derive_client/data_types/models.py | 6 ++-- derive_client/exceptions.py | 4 --- 4 files changed, 39 insertions(+), 26 deletions(-) diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index 1ec74c6b..e7d2d191 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -65,7 +65,6 @@ BridgeEventParseError, BridgePrimarySignerRequiredError, BridgeRouteError, - DrvWithdrawAmountBelowFee, PartialBridgeResult, ) from derive_client.utils import get_prod_derive_addresses @@ -388,7 +387,7 @@ async def _prepare_socket_deposit(self, amount: int, context: BridgeContext) -> token_data, _connector = self._resolve_socket_route(context=context) spender = token_data.Vault if token_data.isNewBridge else self.get_deposit_helper(context.source_chain).address - await ensure_token_balance(context.source_token, self.owner, amount) + await ensure_token_balance(context.source_token, self.owner, amount=amount) await ensure_token_allowance( w3=context.source_w3, token_contract=context.source_token, @@ -413,9 +412,6 @@ async def _prepare_socket_withdrawal(self, amount: int, context: BridgeContext) token_data, connector = self._resolve_socket_route(context=context) - await ensure_token_balance(context.source_token, self.wallet, amount) - await self._check_bridge_funds(token_data, connector, amount) - # Get estimated fee in token for a withdrawal fee_in_token = await self.withdraw_wrapper.functions.getFeeInToken( token=token_data.MintableToken, @@ -423,8 +419,8 @@ async def _prepare_socket_withdrawal(self, amount: int, context: BridgeContext) connector=token_data.connectors[context.target_chain][TARGET_SPEED], gasLimit=MSG_GAS_LIMIT, ).call() - if amount < fee_in_token: - raise DrvWithdrawAmountBelowFee(f"Withdraw amount < fee: {amount} < {fee_in_token} ({(fee_in_token / amount * 100):.2f}%)") + await ensure_token_balance(context.source_token, self.wallet, amount=amount, fee_in_token=fee_in_token) + await self._check_bridge_funds(token_data, connector, amount) kwargs = { "token": context.source_token.address, @@ -444,14 +440,20 @@ async def _prepare_socket_withdrawal(self, amount: int, context: BridgeContext) dest=[context.source_token.address, self.withdraw_wrapper.address], func=[approve_data, bridge_data], ) - prepared_tx = await self._prepare_tx(amount=amount, func=func, value=0, fee_in_token=fee_in_token, context=context) + prepared_tx = await self._prepare_tx( + amount=amount, + func=func, + value=0, + fee_in_token=fee_in_token, + context=context, + ) return prepared_tx async def _prepare_layerzero_deposit(self, amount: int, context: BridgeContext) -> PreparedBridgeTx: # check allowance, if needed approve - await ensure_token_balance(context.source_token, self.owner, amount) + await ensure_token_balance(context.source_token, self.owner, amount=amount) await ensure_token_allowance( w3=context.source_w3, token_contract=context.source_token, @@ -482,7 +484,13 @@ async def _prepare_layerzero_deposit(self, amount: int, context: BridgeContext) refund_address = self.owner func = context.source_token.functions.send(send_params, fees, refund_address) - prepared_tx = await self._prepare_tx(amount=amount, func=func, value=native_fee, fee_in_token=0, context=context) + prepared_tx = await self._prepare_tx( + amount=amount, + func=func, + value=native_fee, + fee_in_token=0, + context=context, + ) return prepared_tx @@ -490,13 +498,14 @@ async def _prepare_layerzero_withdrawal(self, amount: int, context: BridgeContex abi = json.loads(LYRA_OFT_WITHDRAW_WRAPPER_ABI_PATH.read_text()) withdraw_wrapper = get_contract(context.source_w3, LYRA_OFT_WITHDRAW_WRAPPER_ADDRESS, abi=abi) - - await ensure_token_balance(context.source_token, self.wallet, amount) - destEID = LayerZeroChainIDv2[context.target_chain.name] - fee_in_token = await withdraw_wrapper.functions.getFeeInToken(context.source_token.address, amount, destEID).call() - if amount < fee_in_token: - raise DrvWithdrawAmountBelowFee(f"Withdraw amount < fee: {amount} < {fee_in_token} ({(fee_in_token / amount * 100):.2f}%)") + + fee_in_token = await withdraw_wrapper.functions.getFeeInToken( + token=context.source_token.address, + amount=amount, + destEID=destEID, + ).call() + await ensure_token_balance(context.source_token, self.wallet, amount=amount, fee_in_token=fee_in_token) kwargs = { "token": context.source_token.address, @@ -512,7 +521,13 @@ async def _prepare_layerzero_withdrawal(self, amount: int, context: BridgeContex dest=[context.source_token.address, withdraw_wrapper.address], func=[approve_data, bridge_data], ) - prepared_tx = await self._prepare_tx(amount=amount, func=func, value=0, fee_in_token=fee_in_token, context=context) + prepared_tx = await self._prepare_tx( + amount=amount, + func=func, + value=0, + fee_in_token=fee_in_token, + context=context, + ) return prepared_tx diff --git a/derive_client/_bridge/w3.py b/derive_client/_bridge/w3.py index a7180fff..bb181f32 100644 --- a/derive_client/_bridge/w3.py +++ b/derive_client/_bridge/w3.py @@ -182,11 +182,13 @@ def get_erc20_contract(w3: AsyncWeb3, token_address: str) -> AsyncContract: return get_contract(w3=w3, address=token_address, abi=abi) -async def ensure_token_balance(token_contract: Contract, owner: Address, amount: int): +async def ensure_token_balance(token_contract: Contract, owner: Address, amount: int, fee_in_token: int = 0): balance = await token_contract.functions.balanceOf(owner).call() + required = amount + fee_in_token if amount > balance: raise InsufficientTokenBalance( - f"Not enough tokens to withdraw: {amount} < {balance} ({(balance / amount * 100):.2f}%)" + f"Not enough tokens for withdraw: required={required} (amount={amount} + fee={fee_in_token}), " + f"balance={balance} ({(balance / required * 100):.2f}% of required)" ) diff --git a/derive_client/data_types/models.py b/derive_client/data_types/models.py index 82252019..c01102e5 100644 --- a/derive_client/data_types/models.py +++ b/derive_client/data_types/models.py @@ -17,6 +17,7 @@ from web3.datastructures import AttributeDict from derive_client.exceptions import TxReceiptMissing + from .enums import ( BridgeType, ChainID, @@ -30,7 +31,6 @@ ) - class PAttributeDict(AttributeDict): @classmethod @@ -296,14 +296,14 @@ def __post_init_post_parse__(self) -> None: # rule 1: don't allow both amount (erc20) and value (native) to be non-zero if self.amount and self.value: raise ValueError( - f"PreparedBridgeTx: both amount ({self.amount}) and value ({self.value}) are non-zero; " + f"Both amount ({self.amount}) and value ({self.value}) are non-zero; " "use `prepare_erc20_tx` or `prepare_eth_tx` instead." ) # rule 2: don't allow both fee types to be non-zero simultaneously if self.fee_value and self.fee_in_token: raise ValueError( - f"PreparedBridgeTx: both fee_value ({self.fee_value}) and fee_in_token ({self.fee_in_token}) are non-zero; " + f"Both fee_value ({self.fee_value}) and fee_in_token ({self.fee_in_token}) are non-zero; " "fees must be expressed in only one currency." ) diff --git a/derive_client/exceptions.py b/derive_client/exceptions.py index 75826a6e..492ad883 100644 --- a/derive_client/exceptions.py +++ b/derive_client/exceptions.py @@ -95,10 +95,6 @@ class DeriveFundingFailed(Exception): """Raised when funding the Derive wallet with gas fails.""" -class DrvWithdrawAmountBelowFee(Exception): - """Raised when the DRV withdrawal amount is less than the fee required to withdraw.""" - - class TxReceiptMissing(Exception): """Raised when a transaction receipt is required but not available.""" From ace4649d41ae1ed7868e2e66add6a564eb97f423 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Wed, 20 Aug 2025 14:15:29 +0200 Subject: [PATCH 098/101] fix: CLI display of prepared tx --- derive_client/cli.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/derive_client/cli.py b/derive_client/cli.py index d3c6b618..b6e22af8 100644 --- a/derive_client/cli.py +++ b/derive_client/cli.py @@ -53,7 +53,20 @@ def rich_prepared_tx(prepared_tx: PreparedBridgeTx): if prepared_tx.amount > 0: human_amount = from_base_units(amount=prepared_tx.amount, currency=prepared_tx.currency) table.add_row("Amount", f"{human_amount} {prepared_tx.currency.name} (base units: {prepared_tx.amount})") - table.add_row("Value", f"{fmt_sig_up_to(prepared_tx.value / 1e18)} ETH (base units: {prepared_tx.value})") + if prepared_tx.fee_in_token > 0: + fee_human = from_base_units(prepared_tx.fee_in_token, prepared_tx.currency) + table.add_row( + "Estimated fee (token)", + f"{fmt_sig_up_to(fee_human)} {prepared_tx.currency.name} (base units: {prepared_tx.fee_in_token})", + ) + if prepared_tx.value and prepared_tx.value > 0: + human_value = prepared_tx.value / 1e18 + table.add_row("Value", f"{human_value} ETH (base units: {prepared_tx.value})") + if prepared_tx.fee_value > 0: + human_fee_value = fmt_sig_up_to(prepared_tx.fee_value / 1e9) + table.add_row("Estimated fee (native)", f"{human_fee_value} gwei (base units: {prepared_tx.fee_value})") + + # table.add_row("Value", f"{fmt_sig_up_to(prepared_tx.value / 1e18)} ETH (base units: {prepared_tx.value})") table.add_row("Source chain", prepared_tx.source_chain.name) table.add_row("Target chain", prepared_tx.target_chain.name) table.add_row("Bridge type", prepared_tx.bridge_type.name) From 453ec62c243ba8628339d0917cbb2cb535de7385 Mon Sep 17 00:00:00 2001 From: zarathustra Date: Wed, 20 Aug 2025 16:11:55 +0200 Subject: [PATCH 099/101] chore: remove unused custom exceptions --- derive_client/exceptions.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/derive_client/exceptions.py b/derive_client/exceptions.py index 492ad883..01e919b9 100644 --- a/derive_client/exceptions.py +++ b/derive_client/exceptions.py @@ -40,18 +40,10 @@ def __str__(self): return f"{base} [data={self.data!r}]" if self.data is not None else base -class TxSubmissionError(Exception): - """Raised when a transaction could not be signed or submitted.""" - - class BridgeEventParseError(Exception): """Raised when an expected cross-chain bridge event could not be parsed.""" -class AlreadyFinalizedError(Exception): - """Raised when attempting to poll a BridgeTxResult who'se status is not TxStatus.PENDING.""" - - class BridgeRouteError(Exception): """Raised when no bridge route exists for the given currency and chains.""" @@ -87,14 +79,6 @@ class BridgePrimarySignerRequiredError(Exception): """Raised when bridging is attempted with a secondary session-key signer.""" -class EthGasFundingPending(Exception): - """Raised after we detect lack of ETH on Derive to pay for gas.""" - - -class DeriveFundingFailed(Exception): - """Raised when funding the Derive wallet with gas fails.""" - - class TxReceiptMissing(Exception): """Raised when a transaction receipt is required but not available.""" From 7bbb9dc996208ac3d2ff181462e73d2ce25be7c9 Mon Sep 17 00:00:00 2001 From: 8ball030 <8baller@station.codes> Date: Mon, 25 Aug 2025 21:05:25 +0100 Subject: [PATCH 100/101] fix:workflows --- .github/workflows/common_check.yaml | 4 ++-- .github/workflows/dev.yml | 7 +++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/common_check.yaml b/.github/workflows/common_check.yaml index 14fcd2e9..40af8071 100644 --- a/.github/workflows/common_check.yaml +++ b/.github/workflows/common_check.yaml @@ -19,9 +19,9 @@ jobs: fail-fast: false matrix: python-version: - # - "3.9" - "3.10" - poetry-version: ["1.3.2"] + - "3.11" + poetry-version: ["2.0.1"] os: [ubuntu-22.04,] runs-on: ${{ matrix.os }} steps: diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 407e84ca..92cd9f20 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -22,11 +22,10 @@ jobs: strategy: matrix: python-versions: - - 3.8 - - 3.9 + - 3.10 os: - - ubuntu-20.04 - runs-on: ubuntu-20.04 + - ubuntu-24.04 + runs-on: ubuntu-24.04 env: PYTHONPATH: . steps: From 738c397c6473a90770adda1f778f14fd214cf6ef Mon Sep 17 00:00:00 2001 From: 8ball030 <8baller@station.codes> Date: Mon, 25 Aug 2025 21:11:45 +0100 Subject: [PATCH 101/101] chore:linters-typing --- derive_client/cli.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/derive_client/cli.py b/derive_client/cli.py index b6e22af8..4494f4c9 100644 --- a/derive_client/cli.py +++ b/derive_client/cli.py @@ -487,7 +487,7 @@ def fetch_tickers(ctx, instrument_name): ) def transfer_collateral(ctx, amount, to, asset): """Transfer collateral.""" - client = ctx.obj["client"] + client: DeriveClient = ctx.obj["client"] result = client.transfer_collateral(amount=amount, to=to, asset=CollateralAsset(asset)) print(result) @@ -498,7 +498,7 @@ def transfer_collateral(ctx, amount, to, asset): def fetch_subaccounts(ctx): """Fetch subaccounts.""" print("Fetching subaccounts") - client = ctx.obj["client"] + client: DeriveClient = ctx.obj["client"] subaccounts = client.fetch_subaccounts() print(subaccounts) @@ -526,7 +526,7 @@ def fetch_subaccount(ctx, subaccount_id, underlying_currency, columns): print("Fetching subaccount") print(f"Subaccount ID: {subaccount_id}") print(f"Underlying currency: {underlying_currency}") - client = ctx.obj["client"] + client: DeriveClient = ctx.obj["client"] subaccount = client.fetch_subaccount(subaccount_id=subaccount_id) analyser = PortfolioAnalyser(subaccount) print("Positions") @@ -569,7 +569,7 @@ def create_subaccount(ctx, collateral_asset, underlying_currency, subaccount_typ subaccount_type = SubaccountType(subaccount_type) collateral_asset = CollateralAsset(collateral_asset) print(f"Creating subaccount with collateral asset {collateral_asset} and underlying currency {underlying_currency}") - client = ctx.obj["client"] + client: DeriveClient = ctx.obj["client"] subaccount_id = client.create_subaccount( amount=int(amount * 1e6), subaccount_type=subaccount_type, @@ -620,7 +620,7 @@ def create_subaccount(ctx, collateral_asset, underlying_currency, subaccount_typ def fetch_orders(ctx, instrument_name, label, page, page_size, status, regex): """Fetch orders.""" print("Fetching orders") - client = ctx.obj["client"] + client: DeriveClient = ctx.obj["client"] orders = client.fetch_orders( instrument_name=instrument_name, label=label, @@ -684,7 +684,7 @@ def fetch_orders(ctx, instrument_name, label, page, page_size, status, regex): def cancel_order(ctx, order_id, instrument_name): """Cancel order.""" print("Cancelling order") - client = ctx.obj["client"] + client: DeriveClient = ctx.obj["client"] result = client.cancel(order_id=order_id, instrument_name=instrument_name) print(result) @@ -694,7 +694,7 @@ def cancel_order(ctx, order_id, instrument_name): def cancel_all_orders(ctx): """Cancel all orders.""" print("Cancelling all orders") - client = ctx.obj["client"] + client: DeriveClient = ctx.obj["client"] result = client.cancel_all() print(result)