propamm 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ build/
propamm-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lambdaclass
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
propamm-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,127 @@
1
+ Metadata-Version: 2.4
2
+ Name: propamm
3
+ Version: 1.0.0
4
+ Summary: Python SDK for interacting with the PropAMM contracts
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: aiohttp>=3.9
9
+ Requires-Dist: certifi
10
+ Requires-Dist: eth-abi>=5
11
+ Requires-Dist: eth-account>=0.11
12
+ Requires-Dist: eth-utils>=4
13
+ Requires-Dist: web3<8,>=7
14
+ Requires-Dist: websockets>=12
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
17
+ Requires-Dist: pytest>=8; extra == 'dev'
18
+ Requires-Dist: ruff>=0.6; extra == 'dev'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # PropAMM Python SDK
22
+
23
+ An async Python SDK for the `PropAMMRouter` contract, built on
24
+ [web3.py](https://web3py.readthedocs.io/).
25
+ Features:
26
+ - **Typed router bindings**: quote and swap across all whitelisted venues, a
27
+ single venue, or a chosen subset, plus the router's views and ERC-20 approvals.
28
+ - **Accurate quotes**: quotes automatically apply fresh pAMM state overrides,
29
+ so they price live off-chain liquidity rather than stale on-chain state.
30
+ - **Helpers**: amount/unit conversion, slippage, deadlines, and well-known
31
+ token and venue addresses.
32
+ - **A typed error hierarchy**: contract reverts surface as human-readable
33
+ errors (e.g. `InsufficientOutput(...)`).
34
+
35
+ Method names drop the on-chain `V1` suffix (`router.swap(...)` calls `swapV1`).
36
+ A client is read-only by default; pass an account to send transactions.
37
+
38
+ ## Setup
39
+
40
+ Using [uv](https://docs.astral.sh/uv/) (installs the exact pinned versions from `uv.lock`):
41
+
42
+ ```sh
43
+ uv sync --extra dev
44
+ uv run pytest # incl. ABI selector & overrides regression tests
45
+ uv run ruff check .
46
+ ```
47
+
48
+ Using pip (resolves the dependency ranges in `pyproject.toml`):
49
+
50
+ ```sh
51
+ python3 -m venv .venv && source .venv/bin/activate
52
+ pip install -e ".[dev]"
53
+ pytest
54
+ ruff check .
55
+ ```
56
+
57
+ ## Getting started
58
+
59
+ Quote and swap 1 ETH for USDC through the best venue:
60
+
61
+ ```python
62
+ import asyncio
63
+ from propamm import ContractClient, PropAmmRouter, SwapParams
64
+ from propamm.common.accounts import account_from_key
65
+ from propamm.common.helpers import apply_slippage, deadline_in, parse_ether
66
+ from propamm.common.tokens import ETH_SENTINEL, USDC
67
+
68
+ PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" # anvil rich account
69
+ RPC_URL = "http://localhost:8545" # local anvil instance
70
+ ROUTER_ADDRESS = "0x4DdF368080CD7946db5b459aD591c350158175e1" # mainnet router deployment
71
+
72
+ async def main():
73
+ account = account_from_key(PRIVATE_KEY)
74
+ client = ContractClient(RPC_URL, account=account)
75
+ router = PropAmmRouter(client, ROUTER_ADDRESS)
76
+
77
+ amount_in = parse_ether("1")
78
+ quote = await router.quote(ETH_SENTINEL, USDC, amount_in)
79
+
80
+ result = await router.swap_and_wait(
81
+ SwapParams(
82
+ token_in=ETH_SENTINEL,
83
+ token_out=USDC,
84
+ amount_in=amount_in,
85
+ amount_out_min=apply_slippage(quote.amount_out, 50), # quote - 0.5%
86
+ recipient=account.address,
87
+ deadline=deadline_in(300), # now + 5 min
88
+ )
89
+ )
90
+ print(f"received {result.amount_out} USDC via {result.executed_venue}")
91
+
92
+
93
+ asyncio.run(main())
94
+ ```
95
+
96
+ A runnable version lives in [`examples/getting_started.py`](examples/getting_started.py).
97
+
98
+ ```sh
99
+ python3 examples/getting_started.py
100
+ ```
101
+
102
+ It defaults to a local anvil mainnet fork (`anvil --fork-url <mainnet rpc>`)
103
+ with anvil's default funded account and the mainnet router deployment. Override
104
+ with `RPC_URL` / `PRIVATE_KEY` / `ROUTER_ADDRESS` / `SLIPPAGE_BPS`.
105
+
106
+ ## Layout
107
+
108
+ | Module | Purpose |
109
+ | --- | --- |
110
+ | `propamm.client` | `ContractClient`: `AsyncWeb3` wrapper (`contract` / `call_with_overrides` / `send` / `wait_for_transaction`). |
111
+ | `propamm.router` | `PropAmmRouter` bindings: quotes, swaps, ERC-20, views. |
112
+ | `propamm.router.abi` | Vendored router ABI and custom-error naming. |
113
+ | `propamm.overrides` | pAMM state-override sources (`OverridesRpcSource`, `OverridesWsSource`). |
114
+ | `propamm.common` | `tokens`, `pamms`, `helpers`, `accounts`. |
115
+
116
+ ## Quotes & state overrides
117
+
118
+ Quote functions are nonpayable on-chain (not `view`), so they run through
119
+ `eth_call` simulation. By default each quote attaches the latest pAMM state
120
+ overrides (streamed from Titan via `OverridesWsSource`) plus the snapshot's
121
+ block number/timestamp, so venues price fresh off-chain liquidity. Pass
122
+ `QuoteOptions(overrides=None)` to quote without overrides, or supply your own
123
+ `OverridesSource` / `OverridesSnapshot`.
124
+
125
+ > Note: quotes apply fresh overrides, but a fork still *executes* swaps against
126
+ > its frozen state — if a swap reverts with `InsufficientOutput` on a fork,
127
+ > raise the slippage. Live chains fill at the quoted state normally.
@@ -0,0 +1,107 @@
1
+ # PropAMM Python SDK
2
+
3
+ An async Python SDK for the `PropAMMRouter` contract, built on
4
+ [web3.py](https://web3py.readthedocs.io/).
5
+ Features:
6
+ - **Typed router bindings**: quote and swap across all whitelisted venues, a
7
+ single venue, or a chosen subset, plus the router's views and ERC-20 approvals.
8
+ - **Accurate quotes**: quotes automatically apply fresh pAMM state overrides,
9
+ so they price live off-chain liquidity rather than stale on-chain state.
10
+ - **Helpers**: amount/unit conversion, slippage, deadlines, and well-known
11
+ token and venue addresses.
12
+ - **A typed error hierarchy**: contract reverts surface as human-readable
13
+ errors (e.g. `InsufficientOutput(...)`).
14
+
15
+ Method names drop the on-chain `V1` suffix (`router.swap(...)` calls `swapV1`).
16
+ A client is read-only by default; pass an account to send transactions.
17
+
18
+ ## Setup
19
+
20
+ Using [uv](https://docs.astral.sh/uv/) (installs the exact pinned versions from `uv.lock`):
21
+
22
+ ```sh
23
+ uv sync --extra dev
24
+ uv run pytest # incl. ABI selector & overrides regression tests
25
+ uv run ruff check .
26
+ ```
27
+
28
+ Using pip (resolves the dependency ranges in `pyproject.toml`):
29
+
30
+ ```sh
31
+ python3 -m venv .venv && source .venv/bin/activate
32
+ pip install -e ".[dev]"
33
+ pytest
34
+ ruff check .
35
+ ```
36
+
37
+ ## Getting started
38
+
39
+ Quote and swap 1 ETH for USDC through the best venue:
40
+
41
+ ```python
42
+ import asyncio
43
+ from propamm import ContractClient, PropAmmRouter, SwapParams
44
+ from propamm.common.accounts import account_from_key
45
+ from propamm.common.helpers import apply_slippage, deadline_in, parse_ether
46
+ from propamm.common.tokens import ETH_SENTINEL, USDC
47
+
48
+ PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" # anvil rich account
49
+ RPC_URL = "http://localhost:8545" # local anvil instance
50
+ ROUTER_ADDRESS = "0x4DdF368080CD7946db5b459aD591c350158175e1" # mainnet router deployment
51
+
52
+ async def main():
53
+ account = account_from_key(PRIVATE_KEY)
54
+ client = ContractClient(RPC_URL, account=account)
55
+ router = PropAmmRouter(client, ROUTER_ADDRESS)
56
+
57
+ amount_in = parse_ether("1")
58
+ quote = await router.quote(ETH_SENTINEL, USDC, amount_in)
59
+
60
+ result = await router.swap_and_wait(
61
+ SwapParams(
62
+ token_in=ETH_SENTINEL,
63
+ token_out=USDC,
64
+ amount_in=amount_in,
65
+ amount_out_min=apply_slippage(quote.amount_out, 50), # quote - 0.5%
66
+ recipient=account.address,
67
+ deadline=deadline_in(300), # now + 5 min
68
+ )
69
+ )
70
+ print(f"received {result.amount_out} USDC via {result.executed_venue}")
71
+
72
+
73
+ asyncio.run(main())
74
+ ```
75
+
76
+ A runnable version lives in [`examples/getting_started.py`](examples/getting_started.py).
77
+
78
+ ```sh
79
+ python3 examples/getting_started.py
80
+ ```
81
+
82
+ It defaults to a local anvil mainnet fork (`anvil --fork-url <mainnet rpc>`)
83
+ with anvil's default funded account and the mainnet router deployment. Override
84
+ with `RPC_URL` / `PRIVATE_KEY` / `ROUTER_ADDRESS` / `SLIPPAGE_BPS`.
85
+
86
+ ## Layout
87
+
88
+ | Module | Purpose |
89
+ | --- | --- |
90
+ | `propamm.client` | `ContractClient`: `AsyncWeb3` wrapper (`contract` / `call_with_overrides` / `send` / `wait_for_transaction`). |
91
+ | `propamm.router` | `PropAmmRouter` bindings: quotes, swaps, ERC-20, views. |
92
+ | `propamm.router.abi` | Vendored router ABI and custom-error naming. |
93
+ | `propamm.overrides` | pAMM state-override sources (`OverridesRpcSource`, `OverridesWsSource`). |
94
+ | `propamm.common` | `tokens`, `pamms`, `helpers`, `accounts`. |
95
+
96
+ ## Quotes & state overrides
97
+
98
+ Quote functions are nonpayable on-chain (not `view`), so they run through
99
+ `eth_call` simulation. By default each quote attaches the latest pAMM state
100
+ overrides (streamed from Titan via `OverridesWsSource`) plus the snapshot's
101
+ block number/timestamp, so venues price fresh off-chain liquidity. Pass
102
+ `QuoteOptions(overrides=None)` to quote without overrides, or supply your own
103
+ `OverridesSource` / `OverridesSnapshot`.
104
+
105
+ > Note: quotes apply fresh overrides, but a fork still *executes* swaps against
106
+ > its frozen state — if a swap reverts with `InsufficientOutput` on a fork,
107
+ > raise the slippage. Live chains fill at the quoted state normally.
@@ -0,0 +1,77 @@
1
+ """Getting started: quote and swap 1 ETH for USDC through the best venue.
2
+
3
+ Install the SDK first, then run:
4
+
5
+ pip install -e .
6
+ python3 examples/getting_started.py
7
+
8
+ Defaults target a local anvil mainnet fork (`anvil --fork-url <mainnet rpc>`)
9
+ with anvil's default funded account and the mainnet router deployment.
10
+ Override with RPC_URL / PRIVATE_KEY / ROUTER_ADDRESS / SLIPPAGE_BPS.
11
+
12
+ Note: quotes automatically apply fresh pAMM state overrides (streamed from
13
+ Titan), but a fork still *executes* swaps against its frozen state — if the
14
+ swap reverts with `InsufficientOutput` there, raise SLIPPAGE_BPS (live chains
15
+ fill at the quoted state normally).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import os
22
+
23
+ from propamm import ContractClient, PropAmmRouter, SwapParams
24
+ from propamm.common.accounts import account_from_key
25
+ from propamm.common.helpers import (
26
+ apply_slippage,
27
+ deadline_in,
28
+ format_ether,
29
+ format_units,
30
+ parse_ether,
31
+ )
32
+ from propamm.common.tokens import ETH_SENTINEL, USDC
33
+
34
+ USDC_DECIMALS = 6
35
+
36
+ RPC_URL = os.getenv("RPC_URL", "http://localhost:8545")
37
+ # anvil's default funded account #0
38
+ PRIVATE_KEY = os.getenv(
39
+ "PRIVATE_KEY", "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
40
+ )
41
+ # mainnet router deployment
42
+ ROUTER_ADDRESS = os.getenv("ROUTER_ADDRESS", "0x4DdF368080CD7946db5b459aD591c350158175e1")
43
+ SLIPPAGE_BPS = int(os.getenv("SLIPPAGE_BPS", "50"))
44
+
45
+
46
+ async def main() -> None:
47
+ account = account_from_key(PRIVATE_KEY)
48
+ client = ContractClient(RPC_URL, account=account)
49
+ router = PropAmmRouter(client, ROUTER_ADDRESS)
50
+
51
+ amount_in = parse_ether("1")
52
+ quote = await router.quote(ETH_SENTINEL, USDC, amount_in)
53
+ print(
54
+ f"quote: {format_ether(amount_in)} ETH -> "
55
+ f"{format_units(quote.amount_out, USDC_DECIMALS)} USDC via {quote.venue}"
56
+ )
57
+
58
+ result = await router.swap_and_wait(
59
+ SwapParams(
60
+ token_in=ETH_SENTINEL,
61
+ token_out=USDC,
62
+ amount_in=amount_in,
63
+ amount_out_min=apply_slippage(quote.amount_out, SLIPPAGE_BPS),
64
+ recipient=account.address,
65
+ deadline=deadline_in(300), # now + 5 min
66
+ )
67
+ )
68
+ print(
69
+ f"swapped: received {format_units(result.amount_out, USDC_DECIMALS)} USDC "
70
+ f"via {result.executed_venue} (tx {result.hash})"
71
+ )
72
+
73
+ await router.overrides.close()
74
+
75
+
76
+ if __name__ == "__main__":
77
+ asyncio.run(main())
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "propamm"
7
+ version = "1.0.0"
8
+ description = "Python SDK for interacting with the PropAMM contracts"
9
+ license = "MIT"
10
+ license-files = ["LICENSE"]
11
+ readme = "README.md"
12
+ requires-python = ">=3.10"
13
+ dependencies = [
14
+ "web3>=7,<8",
15
+ "eth-abi>=5",
16
+ "eth-utils>=4",
17
+ "eth-account>=0.11",
18
+ "aiohttp>=3.9",
19
+ "websockets>=12",
20
+ "certifi",
21
+ ]
22
+
23
+ [project.optional-dependencies]
24
+ dev = [
25
+ "pytest>=8",
26
+ "pytest-asyncio>=0.23",
27
+ "ruff>=0.6",
28
+ ]
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/propamm"]
32
+
33
+ [tool.pytest.ini_options]
34
+ asyncio_mode = "auto"
35
+ testpaths = ["tests"]
36
+
37
+ [tool.ruff]
38
+ line-length = 100
39
+ src = ["src", "examples", "tests"]
@@ -0,0 +1,62 @@
1
+ """Python SDK for interacting with the PropAMM contracts over JSON-RPC.
2
+
3
+ Provides a generic :class:`ContractClient`, typed :class:`PropAmmRouter`
4
+ bindings (quotes, swaps, and views), and pAMM state-override sources so quotes
5
+ price fresh off-chain liquidity.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .client import ContractClient
11
+ from .error import (
12
+ ClientError,
13
+ InvalidInputError,
14
+ MissingEventError,
15
+ OverridesError,
16
+ PropAmmError,
17
+ RevertError,
18
+ TimeoutError,
19
+ TransactionRevertedError,
20
+ )
21
+ from .overrides import (
22
+ OverridesRpcSource,
23
+ OverridesSnapshot,
24
+ OverridesSource,
25
+ OverridesWsSource,
26
+ )
27
+ from .router import (
28
+ MAX_FEE_BPS,
29
+ FeeCharged,
30
+ FrontendFee,
31
+ PropAmmRouter,
32
+ Quote,
33
+ QuoteOptions,
34
+ SwapOptions,
35
+ SwapParams,
36
+ SwapResult,
37
+ )
38
+
39
+ __all__ = [
40
+ "MAX_FEE_BPS",
41
+ "ClientError",
42
+ "ContractClient",
43
+ "FeeCharged",
44
+ "FrontendFee",
45
+ "InvalidInputError",
46
+ "MissingEventError",
47
+ "OverridesError",
48
+ "OverridesRpcSource",
49
+ "OverridesSnapshot",
50
+ "OverridesSource",
51
+ "OverridesWsSource",
52
+ "PropAmmError",
53
+ "PropAmmRouter",
54
+ "Quote",
55
+ "QuoteOptions",
56
+ "RevertError",
57
+ "SwapOptions",
58
+ "SwapParams",
59
+ "SwapResult",
60
+ "TimeoutError",
61
+ "TransactionRevertedError",
62
+ ]
@@ -0,0 +1,19 @@
1
+ """Shared TLS context backed by certifi's CA bundle.
2
+
3
+ The system cert store is empty on some Python builds (e.g. python.org installers
4
+ on macOS), which makes every TLS verification fail. Using certifi's bundled
5
+ Mozilla roots avoids depending on how Python was installed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ssl
11
+ from functools import lru_cache
12
+
13
+ import certifi
14
+
15
+
16
+ @lru_cache(maxsize=1)
17
+ def ssl_context() -> ssl.SSLContext:
18
+ """A default TLS context that verifies against certifi's CA bundle."""
19
+ return ssl.create_default_context(cafile=certifi.where())
@@ -0,0 +1,180 @@
1
+ """Thin wrapper around ``AsyncWeb3`` for reading from and writing to contracts.
2
+
3
+ web3.py does the heavy lifting — contract calls, ABI encode/decode, event
4
+ decoding, transaction building/signing. The one thing it doesn't expose is
5
+ ``eth_call`` with a *block* override (the 4th RPC parameter), which on-chain
6
+ quotes need so venues see a matching block context; :meth:`call_with_overrides`
7
+ fills that gap with a raw request and re-shapes reverts into :class:`RevertError`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+ from eth_account.signers.local import LocalAccount
15
+ from eth_typing import ChecksumAddress
16
+ from eth_utils import to_checksum_address
17
+ from web3 import AsyncWeb3
18
+ from web3.contract import AsyncContract
19
+ from web3.contract.async_contract import AsyncContractFunction
20
+ from web3.exceptions import ContractLogicError, Web3Exception
21
+
22
+ from ._tls import ssl_context
23
+ from .common.accounts import account_from_key
24
+ from .error import ClientError, RevertError
25
+
26
+ #: How long ``wait_for_transaction`` polls before giving up, in seconds.
27
+ RECEIPT_TIMEOUT_SECONDS = 120
28
+
29
+
30
+ class ContractClient:
31
+ """JSON-RPC contract client.
32
+
33
+ Construct read-only with ``ContractClient(rpc_url)`` (quotes and views
34
+ work; sends don't) or signing with ``ContractClient(rpc_url, account=...)``
35
+ / :meth:`connect_with_signer`.
36
+ """
37
+
38
+ def __init__(self, rpc_url: str, account: LocalAccount | None = None) -> None:
39
+ # Verify TLS against certifi's CA bundle rather than the (sometimes empty)
40
+ # system store, so an https RPC works regardless of how Python was installed.
41
+ request_kwargs = {"ssl": ssl_context()} if rpc_url.startswith("https") else {}
42
+ self.w3 = AsyncWeb3(AsyncWeb3.AsyncHTTPProvider(rpc_url, request_kwargs=request_kwargs))
43
+ self.account = account
44
+
45
+ @classmethod
46
+ def connect(cls, rpc_url: str) -> ContractClient:
47
+ """Read-only client."""
48
+ return cls(rpc_url)
49
+
50
+ @classmethod
51
+ def connect_with_signer(cls, rpc_url: str, private_key: str) -> ContractClient:
52
+ """Signing client from a 0x-prefixed (or bare) private key."""
53
+ return cls(rpc_url, account_from_key(private_key))
54
+
55
+ @property
56
+ def signer_address(self) -> ChecksumAddress | None:
57
+ """Address of the configured signer, if any."""
58
+ return self.account.address if self.account else None
59
+
60
+ def contract(self, address: str, abi: list) -> AsyncContract:
61
+ """A web3 contract bound to ``address`` and ``abi``."""
62
+ return self.w3.eth.contract(address=to_checksum_address(address), abi=abi)
63
+
64
+ async def call_with_overrides(
65
+ self,
66
+ function: AsyncContractFunction,
67
+ *,
68
+ state_override: dict[str, Any] | None = None,
69
+ block_number: int | None = None,
70
+ block_timestamp: int | None = None,
71
+ ) -> bytes:
72
+ """``eth_call`` a function with state and/or block overrides; return raw data.
73
+
74
+ web3's ``ContractFunction.call`` supports state overrides but not block
75
+ overrides, so this issues the raw request. The caller decodes the
76
+ result with the function's ABI.
77
+ """
78
+ tx: dict[str, Any] = {
79
+ "to": function.address,
80
+ "data": function._encode_transaction_data(),
81
+ }
82
+ if self.signer_address is not None:
83
+ tx["from"] = self.signer_address
84
+
85
+ params: list[Any] = [tx, "latest"]
86
+ block_overrides: dict[str, str] = {}
87
+ if block_number is not None:
88
+ block_overrides["number"] = hex(block_number)
89
+ if block_timestamp is not None:
90
+ block_overrides["time"] = hex(block_timestamp)
91
+ if block_overrides:
92
+ params.extend([state_override or {}, block_overrides])
93
+ elif state_override:
94
+ params.append(state_override)
95
+
96
+ response = await self.w3.provider.make_request("eth_call", params)
97
+ if response.get("error"):
98
+ raise _revert_from_rpc_error(response["error"])
99
+ result = response.get("result")
100
+ if result is None:
101
+ raise ClientError("eth_call returned no result")
102
+ return bytes(self.w3.to_bytes(hexstr=result))
103
+
104
+ async def send(self, function: AsyncContractFunction, value: int | None = None) -> str:
105
+ """Build, sign, and send ``function`` as a transaction. Returns the tx hash.
106
+
107
+ web3 fills nonce, gas, fees, and chain id via ``build_transaction``.
108
+ """
109
+ if self.account is None:
110
+ raise ClientError("ContractClient was created without a signer; sends are unavailable")
111
+ try:
112
+ # web3 fills gas, fees, and chain id in `build_transaction`, but not the
113
+ # nonce — supply it ourselves (pending, so back-to-back sends don't collide).
114
+ nonce = await self.w3.eth.get_transaction_count(self.account.address, "pending")
115
+ tx = await function.build_transaction(
116
+ {"from": self.account.address, "value": value or 0, "nonce": nonce}
117
+ )
118
+ signed = self.account.sign_transaction(tx)
119
+ raw = getattr(signed, "raw_transaction", None) or signed.rawTransaction
120
+ return self.w3.to_hex(await self.w3.eth.send_raw_transaction(raw))
121
+ except ContractLogicError as exc:
122
+ # A revert surfaced during gas estimation (e.g. slippage, deadline).
123
+ # web3 has no ABI context here, so it carries only the raw revert
124
+ # payload — pass it through on `data` so the router can name it.
125
+ raise RevertError(_web3_message(exc), _revert_data(exc)) from exc
126
+ except Web3Exception as exc:
127
+ # Insufficient funds, bad nonce, transport failure, ... — keep the
128
+ # SDK's exception surface consistent (see ClientError's docstring).
129
+ raise ClientError(_web3_message(exc)) from exc
130
+
131
+ async def wait_for_transaction(self, tx_hash: str) -> Any:
132
+ """Wait until a transaction is mined and return its receipt."""
133
+ return await self.w3.eth.wait_for_transaction_receipt(
134
+ tx_hash, timeout=RECEIPT_TIMEOUT_SECONDS
135
+ )
136
+
137
+
138
+ def _revert_data(exc: ContractLogicError) -> bytes | None:
139
+ """The raw revert payload off a web3 contract error, as bytes (or ``None``)."""
140
+ raw = getattr(exc, "data", None)
141
+ if isinstance(raw, (bytes, bytearray)):
142
+ return bytes(raw)
143
+ if isinstance(raw, str) and raw.startswith("0x") and len(raw) > 2:
144
+ try:
145
+ return bytes.fromhex(raw[2:])
146
+ except ValueError:
147
+ return None
148
+ return None
149
+
150
+
151
+ def _web3_message(exc: Web3Exception) -> str:
152
+ """Extract a clean message from a web3 exception.
153
+
154
+ web3 stores ``(message, data)`` in ``args`` (so ``str(exc)`` is an ugly
155
+ tuple), and ``Web3RPCError.message`` is the raw JSON-RPC error object —
156
+ prefer ``.message``, unwrapping the nested ``message`` when it's a dict.
157
+ """
158
+ message = getattr(exc, "message", None)
159
+ if isinstance(message, dict):
160
+ return message.get("message") or str(message)
161
+ return message or str(exc)
162
+
163
+
164
+ def _revert_from_rpc_error(error: dict[str, Any]) -> RevertError:
165
+ """Re-shape a JSON-RPC ``error`` object into a :class:`RevertError`.
166
+
167
+ The revert payload arrives in ``error.data`` (a hex string, sometimes
168
+ nested) on reverts; recover it so the router can name the custom error.
169
+ """
170
+ message = error.get("message", "execution reverted")
171
+ raw = error.get("data")
172
+ if isinstance(raw, dict):
173
+ raw = raw.get("data") or raw.get("result")
174
+ data: bytes | None = None
175
+ if isinstance(raw, str) and raw.startswith("0x") and len(raw) > 2:
176
+ try:
177
+ data = bytes.fromhex(raw[2:])
178
+ except ValueError:
179
+ data = None
180
+ return RevertError(message, data)
@@ -0,0 +1 @@
1
+ """Shared constants and conversion helpers (tokens, pAMMs, units, accounts)."""
@@ -0,0 +1,17 @@
1
+ """Thin wrappers around ``eth_account`` for building signing accounts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from eth_account import Account
6
+ from eth_account.signers.local import LocalAccount
7
+
8
+
9
+ def account_from_key(private_key: str) -> LocalAccount:
10
+ """Build a local signing account from a 0x-prefixed (or bare) private key."""
11
+ return Account.from_key(private_key)
12
+
13
+
14
+ def account_from_mnemonic(mnemonic: str, account_path: str = "m/44'/60'/0'/0/0") -> LocalAccount:
15
+ """Build a local signing account from a BIP-39 mnemonic."""
16
+ Account.enable_unaudited_hdwallet_features()
17
+ return Account.from_mnemonic(mnemonic, account_path=account_path)