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.
@@ -0,0 +1,7 @@
1
+ .mypy_cache/
2
+ .ruff_cache/
3
+ .venv/
4
+ __pycache__/
5
+ build/
6
+ dist/
7
+ *.egg-info/
@@ -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.
@@ -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
+