lends-sdk 0.1.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.
- lends_sdk-0.1.0/.gitignore +7 -0
- lends_sdk-0.1.0/LICENSE +21 -0
- lends_sdk-0.1.0/PKG-INFO +101 -0
- lends_sdk-0.1.0/README.md +52 -0
- lends_sdk-0.1.0/pyproject.toml +55 -0
- lends_sdk-0.1.0/src/lends_sdk/__init__.py +56 -0
- lends_sdk-0.1.0/src/lends_sdk/abi.py +222 -0
- lends_sdk-0.1.0/src/lends_sdk/client.py +355 -0
- lends_sdk-0.1.0/src/lends_sdk/math.py +103 -0
- lends_sdk-0.1.0/src/lends_sdk/py.typed +1 -0
- lends_sdk-0.1.0/src/lends_sdk/transactions.py +109 -0
- lends_sdk-0.1.0/src/lends_sdk/types.py +166 -0
- lends_sdk-0.1.0/tests/test_sdk.py +37 -0
lends_sdk-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 LENDS Protocol
|
|
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.
|
lends_sdk-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lends-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Non-custodial Python SDK for LENDS lending, leUSD, and sleUSD.
|
|
5
|
+
Project-URL: Homepage, https://github.com/lendsprotocol/lends-sdk-python
|
|
6
|
+
Project-URL: Repository, https://github.com/lendsprotocol/lends-sdk-python
|
|
7
|
+
Project-URL: Issues, https://github.com/lendsprotocol/lends-sdk-python/issues
|
|
8
|
+
Author: LENDS Protocol
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 LENDS Protocol
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: defi,ethereum,lending,lends,leusd,sleusd
|
|
32
|
+
Classifier: Development Status :: 3 - Alpha
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
40
|
+
Classifier: Typing :: Typed
|
|
41
|
+
Requires-Python: >=3.10
|
|
42
|
+
Requires-Dist: web3<8,>=7.14
|
|
43
|
+
Provides-Extra: dev
|
|
44
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
45
|
+
Requires-Dist: mypy>=1.15; extra == 'dev'
|
|
46
|
+
Requires-Dist: ruff>=0.11; extra == 'dev'
|
|
47
|
+
Requires-Dist: twine>=6.1; extra == 'dev'
|
|
48
|
+
Description-Content-Type: text/markdown
|
|
49
|
+
|
|
50
|
+
# LENDS Python SDK
|
|
51
|
+
|
|
52
|
+
Typed, non-custodial access to LENDS lending/borrowing, the leUSD PSM, and sleUSD staking.
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
python -m pip install lends-sdk
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Usage
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
import os
|
|
64
|
+
from lends_sdk import LendsClient
|
|
65
|
+
|
|
66
|
+
lends = LendsClient(os.environ["RPC_URL"])
|
|
67
|
+
lends.assert_chain()
|
|
68
|
+
|
|
69
|
+
protocol = lends.get_protocol_state()
|
|
70
|
+
markets = lends.get_markets()
|
|
71
|
+
position = lends.get_position(
|
|
72
|
+
"0x1111111111111111111111111111111111111111",
|
|
73
|
+
markets[0].address,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
amount = 10 ** markets[0].collateral_decimals
|
|
77
|
+
approval = lends.transactions.approve_collateral(markets[0].address, amount)
|
|
78
|
+
deposit = lends.transactions.deposit(markets[0].address, amount)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`PreparedTransaction.as_dict()` returns `to`, `data`, `value`, and `chainId` for a wallet or signing system. The SDK does not accept private keys, sign, or broadcast.
|
|
82
|
+
|
|
83
|
+
All amounts are raw integer token units. leUSD and sleUSD use 6 decimals; collateral and USDG decimals are read on-chain; oracle prices are normalized to 8 decimals. Quotes and projected position values are point-in-time previews. Contract execution is authoritative.
|
|
84
|
+
|
|
85
|
+
## Scope
|
|
86
|
+
|
|
87
|
+
Included: approvals, collateral deposit/withdrawal, borrow/repay, fee accrual, PSM conversion, sleUSD stake/unstake lifecycle, and relevant state/statistics.
|
|
88
|
+
|
|
89
|
+
Excluded: every privileged write, liquidation execution, reserves, harvests, losses, minters, risk settings, pauses, ownership, upgrades, LENDS purchases, locks, liquidity, and vesting.
|
|
90
|
+
|
|
91
|
+
## Development
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
python -m pip install -e ".[dev]"
|
|
95
|
+
python -m unittest discover -s tests
|
|
96
|
+
ruff check .
|
|
97
|
+
mypy
|
|
98
|
+
python -m build
|
|
99
|
+
twine check dist/*
|
|
100
|
+
```
|
|
101
|
+
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# LENDS Python SDK
|
|
2
|
+
|
|
3
|
+
Typed, non-custodial access to LENDS lending/borrowing, the leUSD PSM, and sleUSD staking.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
python -m pip install lends-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import os
|
|
15
|
+
from lends_sdk import LendsClient
|
|
16
|
+
|
|
17
|
+
lends = LendsClient(os.environ["RPC_URL"])
|
|
18
|
+
lends.assert_chain()
|
|
19
|
+
|
|
20
|
+
protocol = lends.get_protocol_state()
|
|
21
|
+
markets = lends.get_markets()
|
|
22
|
+
position = lends.get_position(
|
|
23
|
+
"0x1111111111111111111111111111111111111111",
|
|
24
|
+
markets[0].address,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
amount = 10 ** markets[0].collateral_decimals
|
|
28
|
+
approval = lends.transactions.approve_collateral(markets[0].address, amount)
|
|
29
|
+
deposit = lends.transactions.deposit(markets[0].address, amount)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`PreparedTransaction.as_dict()` returns `to`, `data`, `value`, and `chainId` for a wallet or signing system. The SDK does not accept private keys, sign, or broadcast.
|
|
33
|
+
|
|
34
|
+
All amounts are raw integer token units. leUSD and sleUSD use 6 decimals; collateral and USDG decimals are read on-chain; oracle prices are normalized to 8 decimals. Quotes and projected position values are point-in-time previews. Contract execution is authoritative.
|
|
35
|
+
|
|
36
|
+
## Scope
|
|
37
|
+
|
|
38
|
+
Included: approvals, collateral deposit/withdrawal, borrow/repay, fee accrual, PSM conversion, sleUSD stake/unstake lifecycle, and relevant state/statistics.
|
|
39
|
+
|
|
40
|
+
Excluded: every privileged write, liquidation execution, reserves, harvests, losses, minters, risk settings, pauses, ownership, upgrades, LENDS purchases, locks, liquidity, and vesting.
|
|
41
|
+
|
|
42
|
+
## Development
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
python -m pip install -e ".[dev]"
|
|
46
|
+
python -m unittest discover -s tests
|
|
47
|
+
ruff check .
|
|
48
|
+
mypy
|
|
49
|
+
python -m build
|
|
50
|
+
twine check dist/*
|
|
51
|
+
```
|
|
52
|
+
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "lends-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Non-custodial Python SDK for LENDS lending, leUSD, and sleUSD."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { file = "LICENSE" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "LENDS Protocol" }]
|
|
13
|
+
keywords = ["lends", "lending", "defi", "leusd", "sleusd", "ethereum"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Typing :: Typed"
|
|
24
|
+
]
|
|
25
|
+
dependencies = ["web3>=7.14,<8"]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/lendsprotocol/lends-sdk-python"
|
|
29
|
+
Repository = "https://github.com/lendsprotocol/lends-sdk-python"
|
|
30
|
+
Issues = "https://github.com/lendsprotocol/lends-sdk-python/issues"
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = [
|
|
34
|
+
"build>=1.2",
|
|
35
|
+
"mypy>=1.15",
|
|
36
|
+
"ruff>=0.11",
|
|
37
|
+
"twine>=6.1"
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["src/lends_sdk"]
|
|
42
|
+
|
|
43
|
+
[tool.ruff]
|
|
44
|
+
target-version = "py310"
|
|
45
|
+
line-length = 100
|
|
46
|
+
|
|
47
|
+
[tool.ruff.lint]
|
|
48
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|
|
49
|
+
|
|
50
|
+
[tool.mypy]
|
|
51
|
+
python_version = "3.10"
|
|
52
|
+
strict = true
|
|
53
|
+
packages = ["lends_sdk"]
|
|
54
|
+
mypy_path = "src"
|
|
55
|
+
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from .client import ROBINHOOD_MAINNET, LendsClient
|
|
2
|
+
from .math import (
|
|
3
|
+
BPS_DENOMINATOR,
|
|
4
|
+
LEUSD_SCALE,
|
|
5
|
+
PRICE_SCALE,
|
|
6
|
+
SECONDS_PER_YEAR,
|
|
7
|
+
collateral_value,
|
|
8
|
+
convert_decimals_exact,
|
|
9
|
+
health_factor,
|
|
10
|
+
liquidation_price,
|
|
11
|
+
max_borrow,
|
|
12
|
+
pending_vault_fee,
|
|
13
|
+
scale_price_to_e8,
|
|
14
|
+
withdrawable_collateral,
|
|
15
|
+
)
|
|
16
|
+
from .transactions import LendsTransactions
|
|
17
|
+
from .types import (
|
|
18
|
+
Deployment,
|
|
19
|
+
LiquidationPlan,
|
|
20
|
+
MarketState,
|
|
21
|
+
PositionState,
|
|
22
|
+
PreparedTransaction,
|
|
23
|
+
ProtocolState,
|
|
24
|
+
PsmQuote,
|
|
25
|
+
StakingState,
|
|
26
|
+
TokenState,
|
|
27
|
+
UnstakeRequest,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"BPS_DENOMINATOR",
|
|
32
|
+
"LEUSD_SCALE",
|
|
33
|
+
"PRICE_SCALE",
|
|
34
|
+
"ROBINHOOD_MAINNET",
|
|
35
|
+
"SECONDS_PER_YEAR",
|
|
36
|
+
"Deployment",
|
|
37
|
+
"LendsClient",
|
|
38
|
+
"LendsTransactions",
|
|
39
|
+
"LiquidationPlan",
|
|
40
|
+
"MarketState",
|
|
41
|
+
"PositionState",
|
|
42
|
+
"PreparedTransaction",
|
|
43
|
+
"ProtocolState",
|
|
44
|
+
"PsmQuote",
|
|
45
|
+
"StakingState",
|
|
46
|
+
"TokenState",
|
|
47
|
+
"UnstakeRequest",
|
|
48
|
+
"collateral_value",
|
|
49
|
+
"convert_decimals_exact",
|
|
50
|
+
"health_factor",
|
|
51
|
+
"liquidation_price",
|
|
52
|
+
"max_borrow",
|
|
53
|
+
"pending_vault_fee",
|
|
54
|
+
"scale_price_to_e8",
|
|
55
|
+
"withdrawable_collateral",
|
|
56
|
+
]
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Minimal public ABIs. Privileged state-changing functions are intentionally absent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _io(name: str, type_: str, components: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
|
9
|
+
item: dict[str, Any] = {"name": name, "type": type_}
|
|
10
|
+
if components is not None:
|
|
11
|
+
item["components"] = components
|
|
12
|
+
return item
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _fn(
|
|
16
|
+
name: str,
|
|
17
|
+
inputs: list[dict[str, Any]] | None = None,
|
|
18
|
+
outputs: list[dict[str, Any]] | None = None,
|
|
19
|
+
mutability: str = "view",
|
|
20
|
+
) -> dict[str, Any]:
|
|
21
|
+
return {
|
|
22
|
+
"type": "function",
|
|
23
|
+
"name": name,
|
|
24
|
+
"stateMutability": mutability,
|
|
25
|
+
"inputs": inputs or [],
|
|
26
|
+
"outputs": outputs or [],
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
U256 = _io("", "uint256")
|
|
31
|
+
ADDRESS = _io("", "address")
|
|
32
|
+
BOOL = _io("", "bool")
|
|
33
|
+
|
|
34
|
+
LENDING_ABI = [
|
|
35
|
+
_fn("leusd", outputs=[ADDRESS]),
|
|
36
|
+
_fn("usdg", outputs=[ADDRESS]),
|
|
37
|
+
_fn("usdgDecimals", outputs=[_io("", "uint8")]),
|
|
38
|
+
_fn("paused", outputs=[BOOL]),
|
|
39
|
+
_fn("collateralCount", outputs=[U256]),
|
|
40
|
+
_fn("collateralList", [_io("", "uint256")], [ADDRESS]),
|
|
41
|
+
_fn(
|
|
42
|
+
"collateralConfigs",
|
|
43
|
+
[_io("", "address")],
|
|
44
|
+
[
|
|
45
|
+
_io("enabled", "bool"),
|
|
46
|
+
_io("token", "address"),
|
|
47
|
+
_io("priceFeed", "address"),
|
|
48
|
+
_io("symbol", "bytes32"),
|
|
49
|
+
_io("collateralDecimals", "uint8"),
|
|
50
|
+
_io("borrowLtvBps", "uint16"),
|
|
51
|
+
_io("liquidationThresholdBps", "uint16"),
|
|
52
|
+
_io("liquidationPenaltyBps", "uint16"),
|
|
53
|
+
_io("closeFactorBps", "uint16"),
|
|
54
|
+
_io("perVaultDebtCap", "uint256"),
|
|
55
|
+
_io("collateralDebtCap", "uint256"),
|
|
56
|
+
_io("depositCapRaw", "uint256"),
|
|
57
|
+
_io("totalDebt", "uint256"),
|
|
58
|
+
_io("totalDepositsRaw", "uint256"),
|
|
59
|
+
_io("depositsPaused", "bool"),
|
|
60
|
+
_io("borrowsPaused", "bool"),
|
|
61
|
+
_io("withdrawsPaused", "bool"),
|
|
62
|
+
_io("marketPaused", "bool"),
|
|
63
|
+
],
|
|
64
|
+
),
|
|
65
|
+
_fn(
|
|
66
|
+
"vaults",
|
|
67
|
+
[_io("", "address"), _io("", "address")],
|
|
68
|
+
[
|
|
69
|
+
_io("collateralRaw", "uint256"),
|
|
70
|
+
_io("principalDebt", "uint256"),
|
|
71
|
+
_io("accruedFee", "uint256"),
|
|
72
|
+
_io("lastAccrualTs", "uint256"),
|
|
73
|
+
],
|
|
74
|
+
),
|
|
75
|
+
_fn("vaultDebt", [_io("", "address"), _io("", "address")], [U256]),
|
|
76
|
+
_fn("healthFactorBps", [_io("", "address"), _io("", "address")], [U256]),
|
|
77
|
+
_fn(
|
|
78
|
+
"liquidationPlan",
|
|
79
|
+
[_io("borrower", "address"), _io("collateral", "address")],
|
|
80
|
+
[
|
|
81
|
+
_io(
|
|
82
|
+
"plan",
|
|
83
|
+
"tuple",
|
|
84
|
+
[
|
|
85
|
+
_io("collateralRaw", "uint256"),
|
|
86
|
+
_io("debtToCover", "uint256"),
|
|
87
|
+
_io("principalToCover", "uint256"),
|
|
88
|
+
_io("feeToCover", "uint256"),
|
|
89
|
+
_io("targetSettlementLeusd", "uint256"),
|
|
90
|
+
_io("fullLiquidation", "bool"),
|
|
91
|
+
_io("healthFactorAfterBps", "uint256"),
|
|
92
|
+
],
|
|
93
|
+
)
|
|
94
|
+
],
|
|
95
|
+
),
|
|
96
|
+
*[
|
|
97
|
+
_fn(name, outputs=[U256])
|
|
98
|
+
for name in (
|
|
99
|
+
"totalDebt",
|
|
100
|
+
"totalUncollectedFees",
|
|
101
|
+
"realizedRevenueForStakers",
|
|
102
|
+
"realizedRevenueForProtocol",
|
|
103
|
+
"insuranceFundLeusd",
|
|
104
|
+
"badDebtLeusd",
|
|
105
|
+
"protocolDebtCap",
|
|
106
|
+
"stabilityFeeAprBps",
|
|
107
|
+
"insuranceTargetBps",
|
|
108
|
+
"insuranceFeeShareBps",
|
|
109
|
+
"psmUsdgLiabilities",
|
|
110
|
+
"psmLeusdSupply",
|
|
111
|
+
"psmIdleUsdgAssets",
|
|
112
|
+
"psmMorphoDeployedUsdgAssets",
|
|
113
|
+
"psmMorphoShares",
|
|
114
|
+
"psmCap",
|
|
115
|
+
"stakerTargetRevenueDue",
|
|
116
|
+
"stakerTargetLastAccrualTs",
|
|
117
|
+
"stakerTargetAprBps",
|
|
118
|
+
"stakingCapacityLeusd",
|
|
119
|
+
"pendingLiquidationPrincipal",
|
|
120
|
+
"pendingLiquidationFees",
|
|
121
|
+
)
|
|
122
|
+
],
|
|
123
|
+
_fn(
|
|
124
|
+
"deposit",
|
|
125
|
+
[_io("collateral", "address"), _io("amount", "uint256")],
|
|
126
|
+
mutability="nonpayable",
|
|
127
|
+
),
|
|
128
|
+
_fn(
|
|
129
|
+
"withdraw",
|
|
130
|
+
[_io("collateral", "address"), _io("amount", "uint256")],
|
|
131
|
+
mutability="nonpayable",
|
|
132
|
+
),
|
|
133
|
+
_fn(
|
|
134
|
+
"accrueFee",
|
|
135
|
+
[_io("borrower", "address"), _io("collateral", "address")],
|
|
136
|
+
mutability="nonpayable",
|
|
137
|
+
),
|
|
138
|
+
_fn(
|
|
139
|
+
"borrow",
|
|
140
|
+
[_io("collateral", "address"), _io("amount", "uint256")],
|
|
141
|
+
mutability="nonpayable",
|
|
142
|
+
),
|
|
143
|
+
_fn(
|
|
144
|
+
"repay",
|
|
145
|
+
[_io("collateral", "address"), _io("maxAmount", "uint256")],
|
|
146
|
+
[_io("feePaid", "uint256"), _io("principalPaid", "uint256"), _io("overpayment", "uint256")],
|
|
147
|
+
"nonpayable",
|
|
148
|
+
),
|
|
149
|
+
_fn("psmSwapIn", [_io("usdgAssets", "uint256")], [_io("leusdAmount", "uint256")], "nonpayable"),
|
|
150
|
+
_fn("psmSwapOut", [_io("leusdAmount", "uint256")], [_io("executed", "bool")], "nonpayable"),
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
ERC20_ABI = [
|
|
154
|
+
_fn("name", outputs=[_io("", "string")]),
|
|
155
|
+
_fn("symbol", outputs=[_io("", "string")]),
|
|
156
|
+
_fn("decimals", outputs=[_io("", "uint8")]),
|
|
157
|
+
_fn("totalSupply", outputs=[U256]),
|
|
158
|
+
_fn("balanceOf", [_io("", "address")], [U256]),
|
|
159
|
+
_fn("allowance", [_io("", "address"), _io("", "address")], [U256]),
|
|
160
|
+
_fn("approve", [_io("spender", "address"), _io("amount", "uint256")], [BOOL], "nonpayable"),
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
STAKING_ABI = [
|
|
164
|
+
*ERC20_ABI,
|
|
165
|
+
_fn("paused", outputs=[BOOL]),
|
|
166
|
+
*[
|
|
167
|
+
_fn(name, outputs=[U256])
|
|
168
|
+
for name in (
|
|
169
|
+
"totalPoolShares",
|
|
170
|
+
"stakingVaultLeusd",
|
|
171
|
+
"accountedAssets",
|
|
172
|
+
"claimableAssets",
|
|
173
|
+
"stakeEntryAssets",
|
|
174
|
+
"totalManagedAssets",
|
|
175
|
+
"realizedLossLeusd",
|
|
176
|
+
"unvestedRevenue",
|
|
177
|
+
"reservedPendingClaims",
|
|
178
|
+
"vestingStartTs",
|
|
179
|
+
"vestingEndTs",
|
|
180
|
+
"lastVestingSyncTs",
|
|
181
|
+
"cooldownSeconds",
|
|
182
|
+
"UNSTAKE_CLAIM_WINDOW_SECONDS",
|
|
183
|
+
"MIN_INITIAL_STAKE_LEUSD",
|
|
184
|
+
"revenueVestingSeconds",
|
|
185
|
+
"nextPendingWithdrawalId",
|
|
186
|
+
)
|
|
187
|
+
],
|
|
188
|
+
_fn(
|
|
189
|
+
"pendingWithdrawals",
|
|
190
|
+
[_io("", "uint256")],
|
|
191
|
+
[
|
|
192
|
+
_io("owner", "address"),
|
|
193
|
+
_io("shares", "uint256"),
|
|
194
|
+
_io("requestTs", "uint256"),
|
|
195
|
+
_io("claimDeadlineTs", "uint256"),
|
|
196
|
+
_io("completed", "bool"),
|
|
197
|
+
],
|
|
198
|
+
),
|
|
199
|
+
_fn(
|
|
200
|
+
"stake",
|
|
201
|
+
[_io("amount", "uint256"), _io("minSharesOut", "uint256")],
|
|
202
|
+
[_io("shares", "uint256")],
|
|
203
|
+
"nonpayable",
|
|
204
|
+
),
|
|
205
|
+
_fn("requestUnstake", [_io("shares", "uint256")], [_io("id", "uint256")], "nonpayable"),
|
|
206
|
+
_fn("completeUnstake", [_io("id", "uint256")], [_io("assets", "uint256")], "nonpayable"),
|
|
207
|
+
_fn("cancelExpiredUnstake", [_io("id", "uint256")], [_io("shares", "uint256")], "nonpayable"),
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
ORACLE_ABI = [
|
|
211
|
+
_fn("decimals", outputs=[_io("", "uint8")]),
|
|
212
|
+
_fn(
|
|
213
|
+
"latestRoundData",
|
|
214
|
+
outputs=[
|
|
215
|
+
_io("roundId", "uint80"),
|
|
216
|
+
_io("answer", "int256"),
|
|
217
|
+
_io("startedAt", "uint256"),
|
|
218
|
+
_io("updatedAt", "uint256"),
|
|
219
|
+
_io("answeredInRound", "uint80"),
|
|
220
|
+
],
|
|
221
|
+
),
|
|
222
|
+
]
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from web3 import HTTPProvider, Web3
|
|
6
|
+
|
|
7
|
+
from .abi import ERC20_ABI, LENDING_ABI, ORACLE_ABI, STAKING_ABI
|
|
8
|
+
from .math import (
|
|
9
|
+
collateral_value,
|
|
10
|
+
convert_decimals_exact,
|
|
11
|
+
health_factor,
|
|
12
|
+
liquidation_price,
|
|
13
|
+
max_borrow,
|
|
14
|
+
pending_vault_fee,
|
|
15
|
+
scale_price_to_e8,
|
|
16
|
+
withdrawable_collateral,
|
|
17
|
+
)
|
|
18
|
+
from .transactions import LendsTransactions
|
|
19
|
+
from .types import (
|
|
20
|
+
Deployment,
|
|
21
|
+
LiquidationPlan,
|
|
22
|
+
MarketState,
|
|
23
|
+
PositionState,
|
|
24
|
+
ProtocolState,
|
|
25
|
+
PsmQuote,
|
|
26
|
+
StakingState,
|
|
27
|
+
TokenState,
|
|
28
|
+
UnstakeRequest,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
ROBINHOOD_MAINNET = Deployment(
|
|
32
|
+
chain_id=4663,
|
|
33
|
+
lending="0x064b2eb0209E85ADAd216bba9B7f115063cd2b24",
|
|
34
|
+
leusd="0x71108CB9c3c0258762F97986535cb018a163675F",
|
|
35
|
+
sleusd="0x347Fd44634eCf2b394a46dF3816cd6d0BDB35FAb",
|
|
36
|
+
usdg="0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class LendsClient:
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
rpc_url: str | None = None,
|
|
44
|
+
*,
|
|
45
|
+
web3: Web3 | None = None,
|
|
46
|
+
deployment: Deployment = ROBINHOOD_MAINNET,
|
|
47
|
+
request_kwargs: dict[str, Any] | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
if web3 is None and not rpc_url:
|
|
50
|
+
raise ValueError("rpc_url or web3 is required")
|
|
51
|
+
self.web3 = web3 or Web3(HTTPProvider(rpc_url, request_kwargs=request_kwargs or {}))
|
|
52
|
+
self.deployment = deployment
|
|
53
|
+
self.transactions = LendsTransactions(deployment)
|
|
54
|
+
self._lending = self.web3.eth.contract(
|
|
55
|
+
address=Web3.to_checksum_address(deployment.lending), abi=LENDING_ABI
|
|
56
|
+
)
|
|
57
|
+
self._staking = self.web3.eth.contract(
|
|
58
|
+
address=Web3.to_checksum_address(deployment.sleusd), abi=STAKING_ABI
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def assert_chain(self) -> None:
|
|
62
|
+
actual = self.web3.eth.chain_id
|
|
63
|
+
if actual != self.deployment.chain_id:
|
|
64
|
+
raise RuntimeError(
|
|
65
|
+
f"RPC chain ID {actual} does not match deployment chain ID "
|
|
66
|
+
f"{self.deployment.chain_id}"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def get_protocol_state(self) -> ProtocolState:
|
|
70
|
+
names = (
|
|
71
|
+
"paused",
|
|
72
|
+
"collateralCount",
|
|
73
|
+
"totalDebt",
|
|
74
|
+
"totalUncollectedFees",
|
|
75
|
+
"realizedRevenueForStakers",
|
|
76
|
+
"realizedRevenueForProtocol",
|
|
77
|
+
"insuranceFundLeusd",
|
|
78
|
+
"badDebtLeusd",
|
|
79
|
+
"protocolDebtCap",
|
|
80
|
+
"stabilityFeeAprBps",
|
|
81
|
+
"insuranceTargetBps",
|
|
82
|
+
"insuranceFeeShareBps",
|
|
83
|
+
"psmUsdgLiabilities",
|
|
84
|
+
"psmLeusdSupply",
|
|
85
|
+
"psmIdleUsdgAssets",
|
|
86
|
+
"psmMorphoDeployedUsdgAssets",
|
|
87
|
+
"psmMorphoShares",
|
|
88
|
+
"psmCap",
|
|
89
|
+
"stakerTargetRevenueDue",
|
|
90
|
+
"stakerTargetLastAccrualTs",
|
|
91
|
+
"stakerTargetAprBps",
|
|
92
|
+
"stakingCapacityLeusd",
|
|
93
|
+
"pendingLiquidationPrincipal",
|
|
94
|
+
"pendingLiquidationFees",
|
|
95
|
+
)
|
|
96
|
+
block = self.web3.eth.get_block("latest")
|
|
97
|
+
state = {
|
|
98
|
+
name: getattr(self._lending.functions, name)().call(block_identifier=block["number"])
|
|
99
|
+
for name in names
|
|
100
|
+
}
|
|
101
|
+
return ProtocolState(
|
|
102
|
+
block_number=block["number"],
|
|
103
|
+
block_timestamp=block["timestamp"],
|
|
104
|
+
paused=state["paused"],
|
|
105
|
+
collateral_count=state["collateralCount"],
|
|
106
|
+
total_debt=state["totalDebt"],
|
|
107
|
+
total_uncollected_fees=state["totalUncollectedFees"],
|
|
108
|
+
realized_revenue_for_stakers=state["realizedRevenueForStakers"],
|
|
109
|
+
realized_revenue_for_protocol=state["realizedRevenueForProtocol"],
|
|
110
|
+
insurance_fund_leusd=state["insuranceFundLeusd"],
|
|
111
|
+
bad_debt_leusd=state["badDebtLeusd"],
|
|
112
|
+
protocol_debt_cap=state["protocolDebtCap"],
|
|
113
|
+
stability_fee_apr_bps=state["stabilityFeeAprBps"],
|
|
114
|
+
insurance_target_bps=state["insuranceTargetBps"],
|
|
115
|
+
insurance_fee_share_bps=state["insuranceFeeShareBps"],
|
|
116
|
+
psm_usdg_liabilities=state["psmUsdgLiabilities"],
|
|
117
|
+
psm_leusd_supply=state["psmLeusdSupply"],
|
|
118
|
+
psm_idle_usdg_assets=state["psmIdleUsdgAssets"],
|
|
119
|
+
psm_morpho_deployed_usdg_assets=state["psmMorphoDeployedUsdgAssets"],
|
|
120
|
+
psm_morpho_shares=state["psmMorphoShares"],
|
|
121
|
+
psm_cap=state["psmCap"],
|
|
122
|
+
staker_target_revenue_due=state["stakerTargetRevenueDue"],
|
|
123
|
+
staker_target_last_accrual_ts=state["stakerTargetLastAccrualTs"],
|
|
124
|
+
staker_target_apr_bps=state["stakerTargetAprBps"],
|
|
125
|
+
staking_capacity_leusd=state["stakingCapacityLeusd"],
|
|
126
|
+
pending_liquidation_principal=state["pendingLiquidationPrincipal"],
|
|
127
|
+
pending_liquidation_fees=state["pendingLiquidationFees"],
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def get_markets(self) -> list[MarketState]:
|
|
131
|
+
count = self._lending.functions.collateralCount().call()
|
|
132
|
+
return [
|
|
133
|
+
self.get_market(self._lending.functions.collateralList(index).call())
|
|
134
|
+
for index in range(count)
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
def get_market(self, collateral: str) -> MarketState:
|
|
138
|
+
address = Web3.to_checksum_address(collateral)
|
|
139
|
+
config = self._lending.functions.collateralConfigs(address).call()
|
|
140
|
+
feed = self.web3.eth.contract(address=config[2], abi=ORACLE_ABI)
|
|
141
|
+
decimals = feed.functions.decimals().call()
|
|
142
|
+
round_data = feed.functions.latestRoundData().call()
|
|
143
|
+
if round_data[1] <= 0 or round_data[3] == 0:
|
|
144
|
+
raise RuntimeError(f"invalid oracle response for {address}")
|
|
145
|
+
symbol = bytes(config[3]).rstrip(b"\x00").decode("utf-8")
|
|
146
|
+
return MarketState(
|
|
147
|
+
address=address,
|
|
148
|
+
enabled=config[0],
|
|
149
|
+
token=config[1],
|
|
150
|
+
price_feed=config[2],
|
|
151
|
+
symbol=symbol,
|
|
152
|
+
collateral_decimals=config[4],
|
|
153
|
+
borrow_ltv_bps=config[5],
|
|
154
|
+
liquidation_threshold_bps=config[6],
|
|
155
|
+
liquidation_penalty_bps=config[7],
|
|
156
|
+
close_factor_bps=config[8],
|
|
157
|
+
per_vault_debt_cap=config[9],
|
|
158
|
+
collateral_debt_cap=config[10],
|
|
159
|
+
deposit_cap_raw=config[11],
|
|
160
|
+
total_debt=config[12],
|
|
161
|
+
total_deposits_raw=config[13],
|
|
162
|
+
deposits_paused=config[14],
|
|
163
|
+
borrows_paused=config[15],
|
|
164
|
+
withdraws_paused=config[16],
|
|
165
|
+
market_paused=config[17],
|
|
166
|
+
oracle_price_e8=scale_price_to_e8(round_data[1], decimals),
|
|
167
|
+
oracle_updated_at=round_data[3],
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def get_position(
|
|
171
|
+
self, owner: str, collateral: str, *, timestamp: int | None = None
|
|
172
|
+
) -> PositionState:
|
|
173
|
+
account = Web3.to_checksum_address(owner)
|
|
174
|
+
market = self.get_market(collateral)
|
|
175
|
+
vault = self._lending.functions.vaults(account, market.address).call()
|
|
176
|
+
fee_apr = self._lending.functions.stabilityFeeAprBps().call()
|
|
177
|
+
stored_debt = vault[1] + vault[2]
|
|
178
|
+
at = (
|
|
179
|
+
int(self.web3.eth.get_block("latest")["timestamp"])
|
|
180
|
+
if timestamp is None
|
|
181
|
+
else timestamp
|
|
182
|
+
)
|
|
183
|
+
pending_fee = pending_vault_fee(stored_debt, fee_apr, vault[3], at)
|
|
184
|
+
debt = stored_debt + pending_fee
|
|
185
|
+
value = collateral_value(
|
|
186
|
+
vault[0], market.collateral_decimals, market.oracle_price_e8
|
|
187
|
+
)
|
|
188
|
+
capacity = max_borrow(value, market.borrow_ltv_bps)
|
|
189
|
+
return PositionState(
|
|
190
|
+
owner=account,
|
|
191
|
+
collateral=market.address,
|
|
192
|
+
collateral_raw=vault[0],
|
|
193
|
+
collateral_value_leusd=value,
|
|
194
|
+
principal_debt=vault[1],
|
|
195
|
+
accrued_fee=vault[2],
|
|
196
|
+
pending_fee=pending_fee,
|
|
197
|
+
debt=debt,
|
|
198
|
+
last_accrual_ts=vault[3],
|
|
199
|
+
health_factor_bps=health_factor(
|
|
200
|
+
value, debt, market.liquidation_threshold_bps
|
|
201
|
+
),
|
|
202
|
+
borrow_capacity=capacity,
|
|
203
|
+
available_to_borrow=max(capacity - debt, 0),
|
|
204
|
+
withdrawable_collateral_raw=withdrawable_collateral(
|
|
205
|
+
vault[0],
|
|
206
|
+
debt,
|
|
207
|
+
market.collateral_decimals,
|
|
208
|
+
market.oracle_price_e8,
|
|
209
|
+
market.borrow_ltv_bps,
|
|
210
|
+
),
|
|
211
|
+
liquidation_price_e8=liquidation_price(
|
|
212
|
+
vault[0], debt, market.collateral_decimals, market.liquidation_threshold_bps
|
|
213
|
+
),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
def get_staking_state(self) -> StakingState:
|
|
217
|
+
names = (
|
|
218
|
+
"paused",
|
|
219
|
+
"totalSupply",
|
|
220
|
+
"totalPoolShares",
|
|
221
|
+
"stakingVaultLeusd",
|
|
222
|
+
"accountedAssets",
|
|
223
|
+
"claimableAssets",
|
|
224
|
+
"stakeEntryAssets",
|
|
225
|
+
"totalManagedAssets",
|
|
226
|
+
"realizedLossLeusd",
|
|
227
|
+
"unvestedRevenue",
|
|
228
|
+
"reservedPendingClaims",
|
|
229
|
+
"vestingStartTs",
|
|
230
|
+
"vestingEndTs",
|
|
231
|
+
"lastVestingSyncTs",
|
|
232
|
+
"cooldownSeconds",
|
|
233
|
+
"UNSTAKE_CLAIM_WINDOW_SECONDS",
|
|
234
|
+
"MIN_INITIAL_STAKE_LEUSD",
|
|
235
|
+
"revenueVestingSeconds",
|
|
236
|
+
"nextPendingWithdrawalId",
|
|
237
|
+
)
|
|
238
|
+
block = self.web3.eth.get_block("latest")
|
|
239
|
+
state = {
|
|
240
|
+
name: getattr(self._staking.functions, name)().call(block_identifier=block["number"])
|
|
241
|
+
for name in names
|
|
242
|
+
}
|
|
243
|
+
return StakingState(
|
|
244
|
+
block_number=block["number"],
|
|
245
|
+
block_timestamp=block["timestamp"],
|
|
246
|
+
paused=state["paused"],
|
|
247
|
+
total_supply=state["totalSupply"],
|
|
248
|
+
total_pool_shares=state["totalPoolShares"],
|
|
249
|
+
staking_vault_leusd=state["stakingVaultLeusd"],
|
|
250
|
+
accounted_assets=state["accountedAssets"],
|
|
251
|
+
claimable_assets=state["claimableAssets"],
|
|
252
|
+
stake_entry_assets=state["stakeEntryAssets"],
|
|
253
|
+
total_managed_assets=state["totalManagedAssets"],
|
|
254
|
+
realized_loss_leusd=state["realizedLossLeusd"],
|
|
255
|
+
unvested_revenue=state["unvestedRevenue"],
|
|
256
|
+
reserved_pending_claims=state["reservedPendingClaims"],
|
|
257
|
+
vesting_start_ts=state["vestingStartTs"],
|
|
258
|
+
vesting_end_ts=state["vestingEndTs"],
|
|
259
|
+
last_vesting_sync_ts=state["lastVestingSyncTs"],
|
|
260
|
+
cooldown_seconds=state["cooldownSeconds"],
|
|
261
|
+
claim_window_seconds=state["UNSTAKE_CLAIM_WINDOW_SECONDS"],
|
|
262
|
+
min_initial_stake_leusd=state["MIN_INITIAL_STAKE_LEUSD"],
|
|
263
|
+
revenue_vesting_seconds=state["revenueVestingSeconds"],
|
|
264
|
+
next_pending_withdrawal_id=state["nextPendingWithdrawalId"],
|
|
265
|
+
capacity_leusd=self._lending.functions.stakingCapacityLeusd().call(
|
|
266
|
+
block_identifier=block["number"]
|
|
267
|
+
),
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def get_unstake_request(self, request_id: int) -> UnstakeRequest:
|
|
271
|
+
if request_id < 0:
|
|
272
|
+
raise ValueError("request_id cannot be negative")
|
|
273
|
+
owner, shares, requested, deadline, completed = (
|
|
274
|
+
self._staking.functions.pendingWithdrawals(request_id).call()
|
|
275
|
+
)
|
|
276
|
+
return UnstakeRequest(
|
|
277
|
+
id=request_id,
|
|
278
|
+
owner=owner,
|
|
279
|
+
shares=shares,
|
|
280
|
+
request_ts=requested,
|
|
281
|
+
claim_deadline_ts=deadline,
|
|
282
|
+
completed=completed,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
def get_liquidation_plan(self, owner: str, collateral: str) -> LiquidationPlan:
|
|
286
|
+
plan = self._lending.functions.liquidationPlan(
|
|
287
|
+
Web3.to_checksum_address(owner), Web3.to_checksum_address(collateral)
|
|
288
|
+
).call()
|
|
289
|
+
return LiquidationPlan(
|
|
290
|
+
collateral_raw=plan[0],
|
|
291
|
+
debt_to_cover=plan[1],
|
|
292
|
+
principal_to_cover=plan[2],
|
|
293
|
+
fee_to_cover=plan[3],
|
|
294
|
+
target_settlement_leusd=plan[4],
|
|
295
|
+
full_liquidation=plan[5],
|
|
296
|
+
health_factor_after_bps=plan[6],
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
def get_token_state(
|
|
300
|
+
self, token: str, owner: str, spenders: tuple[str, ...] = ()
|
|
301
|
+
) -> TokenState:
|
|
302
|
+
address = Web3.to_checksum_address(token)
|
|
303
|
+
account = Web3.to_checksum_address(owner)
|
|
304
|
+
contract = self.web3.eth.contract(address=address, abi=ERC20_ABI)
|
|
305
|
+
return TokenState(
|
|
306
|
+
address=address,
|
|
307
|
+
name=contract.functions.name().call(),
|
|
308
|
+
symbol=contract.functions.symbol().call(),
|
|
309
|
+
decimals=contract.functions.decimals().call(),
|
|
310
|
+
total_supply=contract.functions.totalSupply().call(),
|
|
311
|
+
balance=contract.functions.balanceOf(account).call(),
|
|
312
|
+
allowances={
|
|
313
|
+
Web3.to_checksum_address(spender): contract.functions.allowance(
|
|
314
|
+
account, Web3.to_checksum_address(spender)
|
|
315
|
+
).call()
|
|
316
|
+
for spender in spenders
|
|
317
|
+
},
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
def quote_psm_swap_in(self, usdg_assets: int) -> PsmQuote:
|
|
321
|
+
state = self.get_protocol_state()
|
|
322
|
+
decimals = self._lending.functions.usdgDecimals().call()
|
|
323
|
+
output = convert_decimals_exact(usdg_assets, decimals, 6)
|
|
324
|
+
if output is None:
|
|
325
|
+
return PsmQuote(usdg_assets, 0, False, "NON_EXACT_AMOUNT")
|
|
326
|
+
if state.paused:
|
|
327
|
+
return PsmQuote(usdg_assets, output, False, "PROTOCOL_PAUSED")
|
|
328
|
+
if state.psm_usdg_liabilities + output > state.psm_cap:
|
|
329
|
+
return PsmQuote(usdg_assets, output, False, "PSM_CAP")
|
|
330
|
+
return PsmQuote(usdg_assets, output, True)
|
|
331
|
+
|
|
332
|
+
def quote_psm_swap_out(self, leusd_amount: int) -> PsmQuote:
|
|
333
|
+
state = self.get_protocol_state()
|
|
334
|
+
decimals = self._lending.functions.usdgDecimals().call()
|
|
335
|
+
output = convert_decimals_exact(leusd_amount, 6, decimals)
|
|
336
|
+
if output is None:
|
|
337
|
+
return PsmQuote(leusd_amount, 0, False, "NON_EXACT_AMOUNT")
|
|
338
|
+
if state.paused:
|
|
339
|
+
return PsmQuote(leusd_amount, output, False, "PROTOCOL_PAUSED")
|
|
340
|
+
if state.bad_debt_leusd > 0:
|
|
341
|
+
return PsmQuote(leusd_amount, output, False, "BAD_DEBT")
|
|
342
|
+
if state.psm_idle_usdg_assets < output:
|
|
343
|
+
return PsmQuote(leusd_amount, output, False, "PSM_LIQUIDITY")
|
|
344
|
+
return PsmQuote(leusd_amount, output, True)
|
|
345
|
+
|
|
346
|
+
def quote_stake(self, amount: int) -> int:
|
|
347
|
+
if amount <= 0:
|
|
348
|
+
raise ValueError("amount must be greater than zero")
|
|
349
|
+
shares = self._staking.functions.totalPoolShares().call()
|
|
350
|
+
if shares == 0:
|
|
351
|
+
return amount
|
|
352
|
+
assets = self._staking.functions.stakeEntryAssets().call()
|
|
353
|
+
if assets == 0:
|
|
354
|
+
raise RuntimeError("staking pool has shares but no entry assets")
|
|
355
|
+
return int(amount * shares // assets)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
BPS_DENOMINATOR = 10_000
|
|
4
|
+
LEUSD_SCALE = 1_000_000
|
|
5
|
+
PRICE_SCALE = 100_000_000
|
|
6
|
+
SECONDS_PER_YEAR = 31_536_000
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _power_of_ten(exponent: int) -> int:
|
|
10
|
+
if exponent < 0:
|
|
11
|
+
raise ValueError("decimal exponent cannot be negative")
|
|
12
|
+
return int(10**exponent)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def pending_vault_fee(debt: int, fee_apr_bps: int, last_accrual_ts: int, timestamp: int) -> int:
|
|
16
|
+
if debt <= 0 or fee_apr_bps <= 0 or timestamp <= last_accrual_ts:
|
|
17
|
+
return 0
|
|
18
|
+
return debt * fee_apr_bps * (timestamp - last_accrual_ts) // (
|
|
19
|
+
BPS_DENOMINATOR * SECONDS_PER_YEAR
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def scale_price_to_e8(answer: int, decimals: int) -> int:
|
|
24
|
+
if answer <= 0:
|
|
25
|
+
raise ValueError("oracle answer must be positive")
|
|
26
|
+
if decimals < 0:
|
|
27
|
+
raise ValueError("oracle decimals cannot be negative")
|
|
28
|
+
if decimals == 8:
|
|
29
|
+
return answer
|
|
30
|
+
if decimals < 8:
|
|
31
|
+
return answer * _power_of_ten(8 - decimals)
|
|
32
|
+
return answer // _power_of_ten(decimals - 8)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def collateral_value(collateral_raw: int, collateral_decimals: int, price_e8: int) -> int:
|
|
36
|
+
return (
|
|
37
|
+
collateral_raw
|
|
38
|
+
* price_e8
|
|
39
|
+
* LEUSD_SCALE
|
|
40
|
+
// _power_of_ten(collateral_decimals)
|
|
41
|
+
// PRICE_SCALE
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def max_borrow(value_leusd: int, borrow_ltv_bps: int) -> int:
|
|
46
|
+
return value_leusd * borrow_ltv_bps // BPS_DENOMINATOR
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def health_factor(value_leusd: int, debt_leusd: int, threshold_bps: int) -> int | None:
|
|
50
|
+
return None if debt_leusd == 0 else value_leusd * threshold_bps // debt_leusd
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _ceil_div(numerator: int, denominator: int) -> int:
|
|
54
|
+
if denominator <= 0:
|
|
55
|
+
raise ValueError("denominator must be positive")
|
|
56
|
+
return (numerator + denominator - 1) // denominator
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def withdrawable_collateral(
|
|
60
|
+
collateral_raw: int,
|
|
61
|
+
debt_leusd: int,
|
|
62
|
+
collateral_decimals: int,
|
|
63
|
+
price_e8: int,
|
|
64
|
+
borrow_ltv_bps: int,
|
|
65
|
+
) -> int:
|
|
66
|
+
if debt_leusd == 0:
|
|
67
|
+
return collateral_raw
|
|
68
|
+
if price_e8 <= 0 or borrow_ltv_bps <= 0:
|
|
69
|
+
return 0
|
|
70
|
+
required_value = _ceil_div(debt_leusd * BPS_DENOMINATOR, borrow_ltv_bps)
|
|
71
|
+
required_raw = _ceil_div(
|
|
72
|
+
required_value * _power_of_ten(collateral_decimals) * PRICE_SCALE,
|
|
73
|
+
price_e8 * LEUSD_SCALE,
|
|
74
|
+
)
|
|
75
|
+
return max(collateral_raw - required_raw, 0)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def liquidation_price(
|
|
79
|
+
collateral_raw: int,
|
|
80
|
+
debt_leusd: int,
|
|
81
|
+
collateral_decimals: int,
|
|
82
|
+
liquidation_threshold_bps: int,
|
|
83
|
+
) -> int | None:
|
|
84
|
+
if collateral_raw == 0 or debt_leusd == 0:
|
|
85
|
+
return None
|
|
86
|
+
return _ceil_div(
|
|
87
|
+
debt_leusd
|
|
88
|
+
* BPS_DENOMINATOR
|
|
89
|
+
* _power_of_ten(collateral_decimals)
|
|
90
|
+
* PRICE_SCALE,
|
|
91
|
+
collateral_raw * liquidation_threshold_bps * LEUSD_SCALE,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def convert_decimals_exact(amount: int, from_decimals: int, to_decimals: int) -> int | None:
|
|
96
|
+
if amount < 0:
|
|
97
|
+
raise ValueError("amount cannot be negative")
|
|
98
|
+
if from_decimals == to_decimals:
|
|
99
|
+
return amount
|
|
100
|
+
if from_decimals < to_decimals:
|
|
101
|
+
return amount * _power_of_ten(to_decimals - from_decimals)
|
|
102
|
+
divisor = _power_of_ten(from_decimals - to_decimals)
|
|
103
|
+
return amount // divisor if amount % divisor == 0 else None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from web3 import Web3
|
|
6
|
+
|
|
7
|
+
from .abi import ERC20_ABI, LENDING_ABI, STAKING_ABI
|
|
8
|
+
from .types import Deployment, PreparedTransaction
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _positive(amount: int) -> int:
|
|
12
|
+
if amount <= 0:
|
|
13
|
+
raise ValueError("amount must be greater than zero")
|
|
14
|
+
return amount
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LendsTransactions:
|
|
18
|
+
"""Constructs calldata only. It never signs or broadcasts."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, deployment: Deployment) -> None:
|
|
21
|
+
self.deployment = deployment
|
|
22
|
+
offline = Web3()
|
|
23
|
+
self._lending = offline.eth.contract(
|
|
24
|
+
address=Web3.to_checksum_address(deployment.lending), abi=LENDING_ABI
|
|
25
|
+
)
|
|
26
|
+
self._staking = offline.eth.contract(
|
|
27
|
+
address=Web3.to_checksum_address(deployment.sleusd), abi=STAKING_ABI
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def _encode(self, contract: Any, function: str, args: list[Any]) -> PreparedTransaction:
|
|
31
|
+
return PreparedTransaction(
|
|
32
|
+
to=contract.address,
|
|
33
|
+
data=contract.encode_abi(function, args=args),
|
|
34
|
+
value=0,
|
|
35
|
+
chain_id=self.deployment.chain_id,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def approve(self, token: str, spender: str, amount: int) -> PreparedTransaction:
|
|
39
|
+
if amount < 0:
|
|
40
|
+
raise ValueError("amount cannot be negative")
|
|
41
|
+
contract = Web3().eth.contract(address=Web3.to_checksum_address(token), abi=ERC20_ABI)
|
|
42
|
+
return self._encode(contract, "approve", [Web3.to_checksum_address(spender), amount])
|
|
43
|
+
|
|
44
|
+
def approve_collateral(self, collateral: str, amount: int) -> PreparedTransaction:
|
|
45
|
+
return self.approve(collateral, self.deployment.lending, amount)
|
|
46
|
+
|
|
47
|
+
def approve_leusd_for_repay(self, amount: int) -> PreparedTransaction:
|
|
48
|
+
return self.approve(self.deployment.leusd, self.deployment.lending, amount)
|
|
49
|
+
|
|
50
|
+
def approve_usdg_for_psm(self, amount: int) -> PreparedTransaction:
|
|
51
|
+
return self.approve(self.deployment.usdg, self.deployment.lending, amount)
|
|
52
|
+
|
|
53
|
+
def approve_leusd_for_psm(self, amount: int) -> PreparedTransaction:
|
|
54
|
+
return self.approve(self.deployment.leusd, self.deployment.lending, amount)
|
|
55
|
+
|
|
56
|
+
def approve_leusd_for_staking(self, amount: int) -> PreparedTransaction:
|
|
57
|
+
return self.approve(self.deployment.leusd, self.deployment.sleusd, amount)
|
|
58
|
+
|
|
59
|
+
def deposit(self, collateral: str, amount: int) -> PreparedTransaction:
|
|
60
|
+
return self._encode(
|
|
61
|
+
self._lending, "deposit", [Web3.to_checksum_address(collateral), _positive(amount)]
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def withdraw(self, collateral: str, amount: int) -> PreparedTransaction:
|
|
65
|
+
return self._encode(
|
|
66
|
+
self._lending, "withdraw", [Web3.to_checksum_address(collateral), _positive(amount)]
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def borrow(self, collateral: str, amount: int) -> PreparedTransaction:
|
|
70
|
+
return self._encode(
|
|
71
|
+
self._lending, "borrow", [Web3.to_checksum_address(collateral), _positive(amount)]
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def repay(self, collateral: str, max_amount: int) -> PreparedTransaction:
|
|
75
|
+
return self._encode(
|
|
76
|
+
self._lending, "repay", [Web3.to_checksum_address(collateral), _positive(max_amount)]
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def accrue_fee(self, borrower: str, collateral: str) -> PreparedTransaction:
|
|
80
|
+
return self._encode(
|
|
81
|
+
self._lending,
|
|
82
|
+
"accrueFee",
|
|
83
|
+
[Web3.to_checksum_address(borrower), Web3.to_checksum_address(collateral)],
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def psm_swap_in(self, usdg_assets: int) -> PreparedTransaction:
|
|
87
|
+
return self._encode(self._lending, "psmSwapIn", [_positive(usdg_assets)])
|
|
88
|
+
|
|
89
|
+
def psm_swap_out(self, leusd_amount: int) -> PreparedTransaction:
|
|
90
|
+
return self._encode(self._lending, "psmSwapOut", [_positive(leusd_amount)])
|
|
91
|
+
|
|
92
|
+
def stake(self, amount: int, min_shares_out: int) -> PreparedTransaction:
|
|
93
|
+
return self._encode(
|
|
94
|
+
self._staking, "stake", [_positive(amount), _positive(min_shares_out)]
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def request_unstake(self, shares: int) -> PreparedTransaction:
|
|
98
|
+
return self._encode(self._staking, "requestUnstake", [_positive(shares)])
|
|
99
|
+
|
|
100
|
+
def complete_unstake(self, request_id: int) -> PreparedTransaction:
|
|
101
|
+
if request_id < 0:
|
|
102
|
+
raise ValueError("request_id cannot be negative")
|
|
103
|
+
return self._encode(self._staking, "completeUnstake", [request_id])
|
|
104
|
+
|
|
105
|
+
def cancel_expired_unstake(self, request_id: int) -> PreparedTransaction:
|
|
106
|
+
if request_id < 0:
|
|
107
|
+
raise ValueError("request_id cannot be negative")
|
|
108
|
+
return self._encode(self._staking, "cancelExpiredUnstake", [request_id])
|
|
109
|
+
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True)
|
|
8
|
+
class Deployment:
|
|
9
|
+
chain_id: int
|
|
10
|
+
lending: str
|
|
11
|
+
leusd: str
|
|
12
|
+
sleusd: str
|
|
13
|
+
usdg: str
|
|
14
|
+
deployment_block: int | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class PreparedTransaction:
|
|
19
|
+
to: str
|
|
20
|
+
data: str
|
|
21
|
+
value: int
|
|
22
|
+
chain_id: int
|
|
23
|
+
|
|
24
|
+
def as_dict(self) -> dict[str, str | int]:
|
|
25
|
+
return {"to": self.to, "data": self.data, "value": self.value, "chainId": self.chain_id}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class ProtocolState:
|
|
30
|
+
block_number: int
|
|
31
|
+
block_timestamp: int
|
|
32
|
+
paused: bool
|
|
33
|
+
collateral_count: int
|
|
34
|
+
total_debt: int
|
|
35
|
+
total_uncollected_fees: int
|
|
36
|
+
realized_revenue_for_stakers: int
|
|
37
|
+
realized_revenue_for_protocol: int
|
|
38
|
+
insurance_fund_leusd: int
|
|
39
|
+
bad_debt_leusd: int
|
|
40
|
+
protocol_debt_cap: int
|
|
41
|
+
stability_fee_apr_bps: int
|
|
42
|
+
insurance_target_bps: int
|
|
43
|
+
insurance_fee_share_bps: int
|
|
44
|
+
psm_usdg_liabilities: int
|
|
45
|
+
psm_leusd_supply: int
|
|
46
|
+
psm_idle_usdg_assets: int
|
|
47
|
+
psm_morpho_deployed_usdg_assets: int
|
|
48
|
+
psm_morpho_shares: int
|
|
49
|
+
psm_cap: int
|
|
50
|
+
staker_target_revenue_due: int
|
|
51
|
+
staker_target_last_accrual_ts: int
|
|
52
|
+
staker_target_apr_bps: int
|
|
53
|
+
staking_capacity_leusd: int
|
|
54
|
+
pending_liquidation_principal: int
|
|
55
|
+
pending_liquidation_fees: int
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class MarketState:
|
|
60
|
+
address: str
|
|
61
|
+
enabled: bool
|
|
62
|
+
token: str
|
|
63
|
+
price_feed: str
|
|
64
|
+
symbol: str
|
|
65
|
+
collateral_decimals: int
|
|
66
|
+
borrow_ltv_bps: int
|
|
67
|
+
liquidation_threshold_bps: int
|
|
68
|
+
liquidation_penalty_bps: int
|
|
69
|
+
close_factor_bps: int
|
|
70
|
+
per_vault_debt_cap: int
|
|
71
|
+
collateral_debt_cap: int
|
|
72
|
+
deposit_cap_raw: int
|
|
73
|
+
total_debt: int
|
|
74
|
+
total_deposits_raw: int
|
|
75
|
+
deposits_paused: bool
|
|
76
|
+
borrows_paused: bool
|
|
77
|
+
withdraws_paused: bool
|
|
78
|
+
market_paused: bool
|
|
79
|
+
oracle_price_e8: int
|
|
80
|
+
oracle_updated_at: int
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True)
|
|
84
|
+
class PositionState:
|
|
85
|
+
owner: str
|
|
86
|
+
collateral: str
|
|
87
|
+
collateral_raw: int
|
|
88
|
+
collateral_value_leusd: int
|
|
89
|
+
principal_debt: int
|
|
90
|
+
accrued_fee: int
|
|
91
|
+
pending_fee: int
|
|
92
|
+
debt: int
|
|
93
|
+
last_accrual_ts: int
|
|
94
|
+
health_factor_bps: int | None
|
|
95
|
+
borrow_capacity: int
|
|
96
|
+
available_to_borrow: int
|
|
97
|
+
withdrawable_collateral_raw: int
|
|
98
|
+
liquidation_price_e8: int | None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True, slots=True)
|
|
102
|
+
class StakingState:
|
|
103
|
+
block_number: int
|
|
104
|
+
block_timestamp: int
|
|
105
|
+
paused: bool
|
|
106
|
+
total_supply: int
|
|
107
|
+
total_pool_shares: int
|
|
108
|
+
staking_vault_leusd: int
|
|
109
|
+
accounted_assets: int
|
|
110
|
+
claimable_assets: int
|
|
111
|
+
stake_entry_assets: int
|
|
112
|
+
total_managed_assets: int
|
|
113
|
+
realized_loss_leusd: int
|
|
114
|
+
unvested_revenue: int
|
|
115
|
+
reserved_pending_claims: int
|
|
116
|
+
vesting_start_ts: int
|
|
117
|
+
vesting_end_ts: int
|
|
118
|
+
last_vesting_sync_ts: int
|
|
119
|
+
cooldown_seconds: int
|
|
120
|
+
claim_window_seconds: int
|
|
121
|
+
min_initial_stake_leusd: int
|
|
122
|
+
revenue_vesting_seconds: int
|
|
123
|
+
next_pending_withdrawal_id: int
|
|
124
|
+
capacity_leusd: int
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True, slots=True)
|
|
128
|
+
class UnstakeRequest:
|
|
129
|
+
id: int
|
|
130
|
+
owner: str
|
|
131
|
+
shares: int
|
|
132
|
+
request_ts: int
|
|
133
|
+
claim_deadline_ts: int
|
|
134
|
+
completed: bool
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True, slots=True)
|
|
138
|
+
class LiquidationPlan:
|
|
139
|
+
collateral_raw: int
|
|
140
|
+
debt_to_cover: int
|
|
141
|
+
principal_to_cover: int
|
|
142
|
+
fee_to_cover: int
|
|
143
|
+
target_settlement_leusd: int
|
|
144
|
+
full_liquidation: bool
|
|
145
|
+
health_factor_after_bps: int
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True, slots=True)
|
|
149
|
+
class TokenState:
|
|
150
|
+
address: str
|
|
151
|
+
name: str
|
|
152
|
+
symbol: str
|
|
153
|
+
decimals: int
|
|
154
|
+
total_supply: int
|
|
155
|
+
balance: int
|
|
156
|
+
allowances: dict[str, int]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True, slots=True)
|
|
160
|
+
class PsmQuote:
|
|
161
|
+
input_amount: int
|
|
162
|
+
output_amount: int
|
|
163
|
+
available: bool
|
|
164
|
+
reason: Literal[
|
|
165
|
+
"PROTOCOL_PAUSED", "PSM_CAP", "BAD_DEBT", "PSM_LIQUIDITY", "NON_EXACT_AMOUNT"
|
|
166
|
+
] | None = None
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from lends_sdk import (
|
|
4
|
+
ROBINHOOD_MAINNET,
|
|
5
|
+
LendsTransactions,
|
|
6
|
+
collateral_value,
|
|
7
|
+
pending_vault_fee,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TransactionTests(unittest.TestCase):
|
|
12
|
+
def setUp(self) -> None:
|
|
13
|
+
self.txs = LendsTransactions(ROBINHOOD_MAINNET)
|
|
14
|
+
self.collateral = "0xd0601CE157Db5bdC3162BbaC2a2C8aF5320D9EEC"
|
|
15
|
+
|
|
16
|
+
def test_deposit_calldata(self) -> None:
|
|
17
|
+
tx = self.txs.deposit(self.collateral, 10**18)
|
|
18
|
+
self.assertEqual(tx.to, ROBINHOOD_MAINNET.lending)
|
|
19
|
+
self.assertEqual(tx.value, 0)
|
|
20
|
+
self.assertEqual(tx.data[:10], "0x47e7ef24")
|
|
21
|
+
|
|
22
|
+
def test_zero_borrow_rejected(self) -> None:
|
|
23
|
+
with self.assertRaisesRegex(ValueError, "greater than zero"):
|
|
24
|
+
self.txs.borrow(self.collateral, 0)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MathTests(unittest.TestCase):
|
|
28
|
+
def test_collateral_scaling(self) -> None:
|
|
29
|
+
self.assertEqual(collateral_value(2 * 10**18, 18, 125 * 10**8), 250 * 10**6)
|
|
30
|
+
|
|
31
|
+
def test_fee_floor_rounding(self) -> None:
|
|
32
|
+
self.assertEqual(pending_vault_fee(1_000 * 10**6, 150, 0, 31_536_000), 15 * 10**6)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__":
|
|
36
|
+
unittest.main()
|
|
37
|
+
|