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.
- propamm-1.0.0/.gitignore +7 -0
- propamm-1.0.0/LICENSE +21 -0
- propamm-1.0.0/PKG-INFO +127 -0
- propamm-1.0.0/README.md +107 -0
- propamm-1.0.0/examples/getting_started.py +77 -0
- propamm-1.0.0/pyproject.toml +39 -0
- propamm-1.0.0/src/propamm/__init__.py +62 -0
- propamm-1.0.0/src/propamm/_tls.py +19 -0
- propamm-1.0.0/src/propamm/client.py +180 -0
- propamm-1.0.0/src/propamm/common/__init__.py +1 -0
- propamm-1.0.0/src/propamm/common/accounts.py +17 -0
- propamm-1.0.0/src/propamm/common/helpers.py +88 -0
- propamm-1.0.0/src/propamm/common/pamms.py +21 -0
- propamm-1.0.0/src/propamm/common/tokens.py +14 -0
- propamm-1.0.0/src/propamm/error.py +66 -0
- propamm-1.0.0/src/propamm/overrides/__init__.py +376 -0
- propamm-1.0.0/src/propamm/router/__init__.py +27 -0
- propamm-1.0.0/src/propamm/router/abi.py +77 -0
- propamm-1.0.0/src/propamm/router/bindings.py +398 -0
- propamm-1.0.0/src/propamm/router/propamm_router_abi.json +1566 -0
- propamm-1.0.0/tests/test_abi.py +38 -0
- propamm-1.0.0/tests/test_contract_parity.py +36 -0
- propamm-1.0.0/tests/test_helpers.py +77 -0
- propamm-1.0.0/tests/test_overrides.py +132 -0
- propamm-1.0.0/uv.lock +1956 -0
propamm-1.0.0/.gitignore
ADDED
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.
|
propamm-1.0.0/README.md
ADDED
|
@@ -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)
|