Skip to content

Commit ffe5e86

Browse files
authored
Merge pull request #116 from Strata-Money/main
add (Strata) Tranches integration
2 parents 3e0c90d + 71ffefb commit ffe5e86

File tree

5 files changed

+501
-0
lines changed

5 files changed

+501
-0
lines changed

constants/strata.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""
2+
Constants for Strata Money
3+
"""
4+
5+
STRATA_MAINNET = {
6+
"sUSDe": {
7+
"address": "0x9D39A5DE30e57443BfF2A8307A4256c8797A3497"
8+
},
9+
"tranches": [
10+
{
11+
"name": "srUSDe",
12+
"block": 23492363,
13+
"address": "0x3d7d6fdf07EE548B939A80edbc9B2256d0cdc003"
14+
},
15+
{
16+
"name": "jrUSDe",
17+
"block": 23492392,
18+
"address": "0xC58D044404d8B14e953C115E67823784dEA53d8F"
19+
}
20+
]
21+
}

constants/summary_columns.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,10 @@ class SummaryColumn(Enum):
108108

109109
UNISWAP_V4_POOL_PTS = ("uniswap_v4_pool_pts", SummaryColumnType.ETHENA_PTS)
110110

111+
# Strata Money
112+
STRATA_MONEY_SENIOR = ("strata_srusde_pts", SummaryColumnType.ETHENA_PTS)
113+
STRATA_MONEY_JUNIOR = ("strata_jrusde_pts", SummaryColumnType.ETHENA_PTS)
114+
111115
def __init__(self, column_name: str, col_type: SummaryColumnType):
112116
self.column_name = column_name
113117
self.col_type = col_type

integrations/integration_ids.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,10 @@ class IntegrationID(Enum):
580580
Token.USDE,
581581
)
582582

583+
# Strata Money
584+
STRATA_MONEY_SENIOR = ("strata_srusde", "Strata Senior USDe", Token.SUSDE)
585+
STRATA_MONEY_JUNIOR = ("strata_jrusde", "Strata Junior USDe", Token.SUSDE)
586+
583587
def __init__(self, column_name: str, description: str, token: Token = Token.USDE):
584588
self.column_name = column_name
585589
self.description = description

integrations/strata_jrusde.py

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import logging
2+
import json
3+
from copy import deepcopy
4+
from typing import Dict, List, Optional, Set, TypedDict, Any
5+
from eth_typing import ChecksumAddress
6+
from web3 import Web3
7+
from web3.contract import Contract
8+
from constants.chains import Chain
9+
from constants.example_integrations import PAGINATION_SIZE
10+
from constants.summary_columns import SummaryColumn
11+
from constants.strata import (
12+
STRATA_MAINNET
13+
)
14+
from integrations.cached_balances_integration import CachedBalancesIntegration
15+
from integrations.integration_ids import IntegrationID
16+
from utils.web3_utils import (
17+
ETH_NODE_URL,
18+
call_with_retry,
19+
fetch_events_logs_with_retry,
20+
)
21+
22+
ERC4626_ABI = json.loads(open("abi/ERC4626_abi.json").read())
23+
24+
class StrataJrUSDeIntegration(CachedBalancesIntegration):
25+
def __init__(
26+
self,
27+
integration_id: IntegrationID = IntegrationID.STRATA_MONEY_JUNIOR,
28+
chain: Chain = Chain.ETHEREUM,
29+
summary_cols: Optional[List[SummaryColumn]] = [SummaryColumn.STRATA_MONEY_JUNIOR],
30+
reward_multiplier: int = 5,
31+
excluded_addresses: Optional[Set[ChecksumAddress]] = None,
32+
rpc = ETH_NODE_URL,
33+
strata = STRATA_MAINNET,
34+
):
35+
super().__init__(
36+
integration_id=integration_id,
37+
start_block=min(c["block"] for c in strata["tranches"]),
38+
chain=chain,
39+
summary_cols=summary_cols,
40+
reward_multiplier=reward_multiplier,
41+
excluded_addresses=excluded_addresses
42+
)
43+
# Initialize Web3 provider - use ETH_NODE_URL from environment variables
44+
self.w3 = Web3(Web3.HTTPProvider(rpc))
45+
if not self.w3.is_connected():
46+
logging.error(f"Failed to connect to RPC at {ETH_NODE_URL}")
47+
raise ConnectionError(f"Could not connect to Ethereum RPC at {ETH_NODE_URL}")
48+
logging.info(f"Connected to Ethereum RPC at {ETH_NODE_URL}")
49+
50+
# Initialize tranche
51+
tranche = next((c for c in strata["tranches"] if c["name"] == "jrUSDe"), None)
52+
self.tranche = tranche
53+
self.contract_tranche: Contract = self.w3.eth.contract(
54+
address=tranche["address"],
55+
abi=ERC4626_ABI,
56+
)
57+
self.contract_sUSDe: Contract = self.w3.eth.contract(
58+
address=strata["sUSDe"]["address"],
59+
abi=ERC4626_ABI,
60+
)
61+
62+
63+
def get_balance (self, user: str, block: int) -> float:
64+
balance = call_with_retry(
65+
self.contract_tranche.functions.balanceOf(Web3.to_checksum_address(user)), block = block
66+
)
67+
tranchePps = self.get_tranche_pps(block)
68+
sUSDe_balance = balance * tranchePps
69+
return round(sUSDe_balance / 10**18, 6)
70+
71+
def get_tranche_pps(self, block: int) -> float:
72+
"""
73+
Calculate price per share (in sUSDe) for a given tranche and block and cache result.
74+
1. Calculate pps in USDe
75+
2. Convert USDe pps to sUSDe
76+
3. Cache
77+
"""
78+
79+
# Initialize cache dict once
80+
if not hasattr(self, "_pps_cache"):
81+
self._pps_cache: dict[int, float] = {}
82+
83+
# Return cached PPS if already calculated for this contract and block
84+
if block in self._pps_cache:
85+
return self._pps_cache[block]
86+
87+
pps_tranche_in_usde = self.get_erc4626_pps(self.contract_tranche, block)
88+
pps_susde_in_usde = self.get_erc4626_pps(self.contract_sUSDe, block)
89+
pps = pps_tranche_in_usde / pps_susde_in_usde
90+
91+
# Cache result
92+
self._pps_cache[block] = pps
93+
return pps
94+
95+
def get_erc4626_pps(self, erc4626: Contract, block: int) -> float:
96+
"""
97+
Generic method to calculate price per share for ERC4626 contracts.
98+
"""
99+
total_assets = erc4626.functions.totalAssets().call(block_identifier=block)
100+
total_supply = erc4626.functions.totalSupply().call(block_identifier=block)
101+
pps = float(total_assets) / float(total_supply)
102+
return pps
103+
104+
def convert_block_balances_to_assets(
105+
self, block_balances: Dict[ChecksumAddress, float], block: int
106+
) -> Dict[ChecksumAddress, float]:
107+
"""
108+
Convert Tranche shares to sUSDe assets for a given block.
109+
"""
110+
pps = self.get_tranche_pps(block)
111+
balances_assets = {addr: value * pps for addr, value in block_balances.items()}
112+
return balances_assets
113+
114+
def convert_block_balances_to_shares(
115+
self, block_balances: Dict[ChecksumAddress, float], block: int
116+
) -> Dict[ChecksumAddress, float]:
117+
"""
118+
Convert sUSDe assets to Tranche shares for a given block.
119+
"""
120+
pps = self.get_tranche_pps(block)
121+
balances_shares = {addr: value / pps for addr, value in block_balances.items()}
122+
return balances_shares
123+
124+
125+
def get_block_balances(
126+
self, cached_data: Dict[int, Dict[ChecksumAddress, float]], blocks: List[int]
127+
) -> Dict[int, Dict[ChecksumAddress, float]]:
128+
"""
129+
Get user balances for specified blocks, using cached data when available.
130+
131+
This method returns and caches the balances in sUSDe. Whenever cached data is passed,
132+
we convert it to tranche shares for that block and process new transfer events.
133+
Afterwards, the balances dictionary is converted back to sUSDe balances.
134+
135+
Args:
136+
cached_data: Dictionary mapping block numbers to user balances at that block
137+
blocks: List of block numbers to get balances for
138+
139+
Returns:
140+
Dictionary mapping block numbers to user balances at that block
141+
"""
142+
logging.info(f"[{self.tranche["name"]}] Getting block balances")
143+
144+
new_block_data: Dict[int, Dict[ChecksumAddress, float]] = {}
145+
if not blocks:
146+
return new_block_data
147+
148+
blocks = sorted(blocks)
149+
150+
151+
cache_copy: Dict[int, Dict[ChecksumAddress, float]] = deepcopy(cached_data)
152+
# convert cached data to tokens
153+
cache_copy = {block: self.convert_block_balances_to_shares(balances, block) for block, balances in cache_copy.items()}
154+
155+
for block in blocks:
156+
# find the closest prev block in the data
157+
# list keys parsed as ints and in descending order
158+
sorted_existing_blocks = sorted(
159+
cache_copy,
160+
reverse=True,
161+
)
162+
# loop through the sorted blocks and find the closest previous block
163+
prev_block = self.start_block
164+
start = prev_block
165+
balances = {}
166+
for existing_block in sorted_existing_blocks:
167+
if existing_block < block:
168+
prev_block = existing_block
169+
start = existing_block + 1
170+
balances = deepcopy(cache_copy[prev_block])
171+
break
172+
# parse transfer events since and update balances
173+
while start <= block:
174+
to_block = min(start + PAGINATION_SIZE, block)
175+
transfers = fetch_events_logs_with_retry(
176+
"jrUSDe Token transfers",
177+
self.contract_tranche.events.Transfer(),
178+
start,
179+
to_block,
180+
)
181+
for transfer in transfers:
182+
sender = transfer["args"]["from"]
183+
recipient = transfer["args"]["to"]
184+
if recipient not in balances:
185+
balances[recipient] = 0
186+
if sender not in balances:
187+
balances[sender] = 0
188+
189+
amount_tranche = transfer["args"]["value"]
190+
amount = round(amount_tranche / 10**18, 6)
191+
balances[sender] -= min(amount, balances[sender])
192+
balances[recipient] += amount
193+
194+
start = to_block + 1
195+
196+
balances.pop('0x0000000000000000000000000000000000000000', None)
197+
new_block_data[block] = self.convert_block_balances_to_assets(balances, block)
198+
cache_copy[block] = balances
199+
return new_block_data
200+
201+
202+
203+
if __name__ == "__main__":
204+
example_integration = StrataJrUSDeIntegration()
205+
206+
BLOCK_1 = 23534701
207+
BLOCK_2 = 23548724
208+
209+
current_block = example_integration.w3.eth.get_block_number()
210+
# Without cached data
211+
without_cached_data_output = example_integration.get_block_balances(
212+
cached_data={}, blocks=[ BLOCK_1 ]
213+
)
214+
215+
print("=" * 120)
216+
print("Run without cached data", without_cached_data_output)
217+
print("=" * 120)
218+
219+
# With cached data, using the previous output so there is no need
220+
# to fetch the previous blocks again
221+
with_cached_data_output = example_integration.get_block_balances(
222+
cached_data=without_cached_data_output, blocks=[BLOCK_2]
223+
)
224+
print("Run with cached data", with_cached_data_output)
225+
print("=" * 120)
226+
227+
# Fetch balance in one go up to BLOCK_2 and check the balance for user is the same
228+
integration_1 = StrataJrUSDeIntegration()
229+
balances = integration_1.get_block_balances(
230+
cached_data={}, blocks=[ BLOCK_2 ]
231+
)
232+
233+
user = "0x5071479276AD65a5D7E04230C226c25e2522891a"
234+
print("One-Go-Fetch", balances[BLOCK_2][user])
235+
print("Cache-Fetch", with_cached_data_output[BLOCK_2][user])
236+
print("Balance-Fetch", integration_1.get_balance(user, block=BLOCK_2))

0 commit comments

Comments
 (0)