From d5781ca0a4e55b136487d02efdc5c3e51a61fc57 Mon Sep 17 00:00:00 2001 From: Evgeny Gusarov Date: Wed, 19 Aug 2026 14:15:04 +0300 Subject: [PATCH 1/3] Track boosted shares on vault position instead of reducing minted shares --- .../tests/test_update_redeemable_positions.py | 42 ++++++++++++------- .../commands/update_redeemable_positions.py | 29 ++++++------- src/redemptions/typings.py | 10 ++++- 3 files changed, 49 insertions(+), 32 deletions(-) diff --git a/src/redemptions/commands/tests/test_update_redeemable_positions.py b/src/redemptions/commands/tests/test_update_redeemable_positions.py index 818414b0..07cd61c2 100644 --- a/src/redemptions/commands/tests/test_update_redeemable_positions.py +++ b/src/redemptions/commands/tests/test_update_redeemable_positions.py @@ -11,7 +11,7 @@ from src.config.networks import MAINNET, NETWORKS from src.config.settings import settings from src.redemptions.commands.update_redeemable_positions import ( - _reduce_boosted_amount, + _distribute_boosted_shares, calculate_boost_os_token_shares, create_os_token_positions, update_redeemable_positions, @@ -312,7 +312,7 @@ async def test_calculate_boost_os_token_shares(): } -def test_reduces_boosted_amount(): +def test_distributes_boosted_shares(): address_1 = faker.eth_address() address_2 = faker.eth_address() vault_1 = faker.eth_address() @@ -328,8 +328,8 @@ def test_reduces_boosted_amount(): ) ] boost_ostoken_shares = {} - result, residual = _reduce_boosted_amount(allocators, boost_ostoken_shares) - assert result == [ + residual = _distribute_boosted_shares(allocators, boost_ostoken_shares) + assert allocators == [ Allocator( address=address_1, vault_os_token_positions=[ @@ -361,26 +361,33 @@ def test_reduces_boosted_amount(): (address_2, vault_2): Wei(1500), } - result, residual = _reduce_boosted_amount(allocators, boost_ostoken_shares) - assert result == [ + residual = _distribute_boosted_shares(allocators, boost_ostoken_shares) + assert allocators == [ Allocator( address=address_1, vault_os_token_positions=[ - VaultOsTokenPosition(address=vault_1, minted_shares=Wei(200), ltv=0.5), + VaultOsTokenPosition( + address=vault_1, minted_shares=Wei(500), ltv=0.5, boosted_shares=Wei(300) + ), ], ), Allocator( address=address_2, vault_os_token_positions=[ - VaultOsTokenPosition(address=vault_1, minted_shares=Wei(500), ltv=0.5), - VaultOsTokenPosition(address=vault_2, minted_shares=Wei(500), ltv=0.5), + VaultOsTokenPosition( + address=vault_1, minted_shares=Wei(1000), ltv=0.5, boosted_shares=Wei(500) + ), + VaultOsTokenPosition( + address=vault_2, minted_shares=Wei(2000), ltv=0.5, boosted_shares=Wei(1500) + ), ], ), ] + assert [a.total_shares for a in allocators] == [Wei(200), Wei(1000)] assert residual == {} -def test_reduces_boosted_amount_cross_vault_residual(): +def test_distributes_boosted_shares_cross_vault_residual(): address_1 = faker.eth_address() vault_1 = faker.eth_address() vault_2 = faker.eth_address() @@ -397,8 +404,8 @@ def test_reduces_boosted_amount_cross_vault_residual(): ] boost_ostoken_shares = {(address_1, vault_2): Wei(400)} - result, residual = _reduce_boosted_amount(allocators, boost_ostoken_shares) - assert result == [ + residual = _distribute_boosted_shares(allocators, boost_ostoken_shares) + assert allocators == [ Allocator( address=address_1, vault_os_token_positions=[ @@ -409,7 +416,7 @@ def test_reduces_boosted_amount_cross_vault_residual(): assert residual == {address_1: Wei(400)} -def test_reduces_boosted_amount_excess_over_same_vault_mint_becomes_residual(): +def test_distributes_boosted_shares_excess_over_same_vault_mint_becomes_residual(): address_1 = faker.eth_address() vault_1 = faker.eth_address() @@ -424,15 +431,18 @@ def test_reduces_boosted_amount_excess_over_same_vault_mint_becomes_residual(): ] boost_ostoken_shares = {(address_1, vault_1): Wei(500)} - result, residual = _reduce_boosted_amount(allocators, boost_ostoken_shares) - assert result == [ + residual = _distribute_boosted_shares(allocators, boost_ostoken_shares) + assert allocators == [ Allocator( address=address_1, vault_os_token_positions=[ - VaultOsTokenPosition(address=vault_1, minted_shares=Wei(0), ltv=0.5), + VaultOsTokenPosition( + address=vault_1, minted_shares=Wei(300), ltv=0.5, boosted_shares=Wei(300) + ), ], ), ] + assert allocators[0].total_shares == Wei(0) assert residual == {address_1: Wei(200)} diff --git a/src/redemptions/commands/update_redeemable_positions.py b/src/redemptions/commands/update_redeemable_positions.py index 1f5c2bb1..2f7b1a4c 100644 --- a/src/redemptions/commands/update_redeemable_positions.py +++ b/src/redemptions/commands/update_redeemable_positions.py @@ -239,19 +239,19 @@ async def process( leverage_positions=leverage_positions, os_token_converter=os_token_converter, ) - allocators, residual_boosted_shares = _reduce_boosted_amount(allocators, boost_os_token_shares) + residual_boosted_shares = _distribute_boosted_shares(allocators, boost_os_token_shares) # filter zero positions. Filter before kept shares calculation to reduce api calls - min_minted_shares = Web3.to_wei(min_os_token_position_amount_gwei, 'gwei') + min_redeemable_shares = Web3.to_wei(min_os_token_position_amount_gwei, 'gwei') for allocator in allocators: allocator.vault_os_token_positions = [ vault_share for vault_share in allocator.vault_os_token_positions - if vault_share.minted_shares >= min_minted_shares + if vault_share.redeemable_shares >= min_redeemable_shares ] if not allocators: - logger.info('No allocators with minted shares above the threshold found, exiting...') + logger.info('No allocators with redeemable shares above the threshold found, exiting...') return logger.info('Fetching kept tokens for %s addresses', len(allocators)) @@ -267,7 +267,7 @@ async def process( for address, residual in residual_boosted_shares.items(): kept_shares[address] = Wei(kept_shares[address] + residual) - os_token_positions = create_os_token_positions(allocators, kept_shares, min_minted_shares) + os_token_positions = create_os_token_positions(allocators, kept_shares, min_redeemable_shares) if not os_token_positions: logger.info('No redeemable os token positions to upload, exiting...') return @@ -385,7 +385,7 @@ async def calculate_boost_os_token_shares( def create_os_token_positions( allocators: list[Allocator], kept_shares: dict[ChecksumAddress, Wei], - min_minted_shares: Wei, + min_redeemable_shares: Wei, ) -> list[OsTokenPosition]: """ Calculate vault proportions and create redeemable os token positions. @@ -409,7 +409,7 @@ def create_os_token_positions( else: vault_amount = int(redeemable_amount * proportion) allocated_amount += vault_amount - if vault_amount < min_minted_shares: + if vault_amount < min_redeemable_shares: continue os_token_positions.append( OsTokenPosition( @@ -433,10 +433,10 @@ def _save_positions_to_file(positions_payload: list[dict]) -> Path: return positions_file -def _reduce_boosted_amount( +def _distribute_boosted_shares( allocators: list[Allocator], boost_os_token_shares: dict[tuple[ChecksumAddress, ChecksumAddress], Wei], -) -> tuple[list[Allocator], dict[ChecksumAddress, Wei]]: +) -> dict[ChecksumAddress, Wei]: """ osToken is fungible, so boosted shares aren't necessarily minted at the same vault the leverage strategy borrows against. Match against the same-vault mint first; return @@ -448,14 +448,15 @@ def _reduce_boosted_amount( allocator = allocators_by_address.get(user) if allocator is None: continue - vault_share = allocator.get_vault_position(vault) - matched = min(vault_share.minted_shares, boosted_amount) if vault_share else Wei(0) - if vault_share and matched: - vault_share.minted_shares = Wei(vault_share.minted_shares - matched) + vault_position = allocator.get_vault_position(vault) + matched = Wei(0) + if vault_position: + matched = min(vault_position.minted_shares, boosted_amount) + vault_position.boosted_shares = matched residual = Wei(boosted_amount - matched) if residual: residual_boosted_shares[user] = Wei(residual_boosted_shares[user] + residual) - return allocators, residual_boosted_shares + return residual_boosted_shares async def _startup_check() -> None: diff --git a/src/redemptions/typings.py b/src/redemptions/typings.py index 6d745486..10ce1ac5 100644 --- a/src/redemptions/typings.py +++ b/src/redemptions/typings.py @@ -11,6 +11,12 @@ class VaultOsTokenPosition: address: ChecksumAddress minted_shares: Wei ltv: float + # part of the minted shares that backs a leverage strategy position + boosted_shares: Wei = Wei(0) + + @property + def redeemable_shares(self) -> Wei: + return Wei(max(0, self.minted_shares - self.boosted_shares)) @dataclass @@ -20,14 +26,14 @@ class Allocator: @property def total_shares(self) -> Wei: - return Wei(sum(s.minted_shares for s in self.vault_os_token_positions)) + return Wei(sum(s.redeemable_shares for s in self.vault_os_token_positions)) @property def vaults_proportions(self) -> dict[ChecksumAddress, float]: total = self.total_shares if total == 0: return {} - return {s.address: s.minted_shares / total for s in self.vault_os_token_positions} + return {s.address: s.redeemable_shares / total for s in self.vault_os_token_positions} def get_vault_position(self, vault: ChecksumAddress) -> VaultOsTokenPosition | None: return next((vs for vs in self.vault_os_token_positions if vs.address == vault), None) From 926f6463b07ce2fe1a3559219ed4a2ef62ace399 Mon Sep 17 00:00:00 2001 From: Evgeny Gusarov Date: Wed, 19 Aug 2026 17:34:41 +0300 Subject: [PATCH 2/3] Rename shares vars for redeemable naming consistency --- .../tests/test_update_redeemable_positions.py | 6 ++--- .../commands/update_redeemable_positions.py | 24 +++++++++---------- src/redemptions/typings.py | 4 ++-- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/redemptions/commands/tests/test_update_redeemable_positions.py b/src/redemptions/commands/tests/test_update_redeemable_positions.py index 07cd61c2..63db111b 100644 --- a/src/redemptions/commands/tests/test_update_redeemable_positions.py +++ b/src/redemptions/commands/tests/test_update_redeemable_positions.py @@ -182,7 +182,7 @@ def test_create_os_token_positions_multiple_vaults_3(): ] -def test_create_os_token_positions_min_minted_shares(): +def test_create_os_token_positions_min_redeemable_shares(): address_1 = faker.eth_address() vault_1 = faker.eth_address() vault_2 = faker.eth_address() @@ -383,7 +383,7 @@ def test_distributes_boosted_shares(): ], ), ] - assert [a.total_shares for a in allocators] == [Wei(200), Wei(1000)] + assert [a.total_redeemable_shares for a in allocators] == [Wei(200), Wei(1000)] assert residual == {} @@ -442,7 +442,7 @@ def test_distributes_boosted_shares_excess_over_same_vault_mint_becomes_residual ], ), ] - assert allocators[0].total_shares == Wei(0) + assert allocators[0].total_redeemable_shares == Wei(0) assert residual == {address_1: Wei(200)} diff --git a/src/redemptions/commands/update_redeemable_positions.py b/src/redemptions/commands/update_redeemable_positions.py index 2f7b1a4c..54cc23c1 100644 --- a/src/redemptions/commands/update_redeemable_positions.py +++ b/src/redemptions/commands/update_redeemable_positions.py @@ -245,9 +245,9 @@ async def process( min_redeemable_shares = Web3.to_wei(min_os_token_position_amount_gwei, 'gwei') for allocator in allocators: allocator.vault_os_token_positions = [ - vault_share - for vault_share in allocator.vault_os_token_positions - if vault_share.redeemable_shares >= min_redeemable_shares + vault_position + for vault_position in allocator.vault_os_token_positions + if vault_position.redeemable_shares >= min_redeemable_shares ] if not allocators: @@ -255,13 +255,13 @@ async def process( return logger.info('Fetching kept tokens for %s addresses', len(allocators)) - address_to_minted_shares = {a.address: a.total_shares for a in allocators} + address_to_redeemable_shares = {a.address: a.total_redeemable_shares for a in allocators} kept_shares = await get_kept_shares( - address_to_minted_shares, + address_to_redeemable_shares, block_number, api_config, ) - logger.info('Fetched kept tokens for %s addresses...', len(address_to_minted_shares)) + logger.info('Fetched kept tokens for %s addresses...', len(address_to_redeemable_shares)) # unmatched boosted shares are still held by the user, so keep them out of redeemable # amounts by treating them as kept. for address, residual in residual_boosted_shares.items(): @@ -308,7 +308,7 @@ async def process( # pylint: disable-next=too-many-locals async def get_kept_shares( - address_to_minted_shares: dict[ChecksumAddress, Wei], + address_to_redeemable_shares: dict[ChecksumAddress, Wei], block_number: BlockNumber, api_config: ApiConfig, ) -> dict[ChecksumAddress, Wei]: @@ -317,7 +317,7 @@ async def get_kept_shares( 'Fetching %s balances from the subgraph...', settings.network_config.OS_TOKEN_BALANCE_SYMBOL ) os_token_holders = await graph_get_os_token_holders(block_number) - for address in address_to_minted_shares.keys(): + for address in address_to_redeemable_shares.keys(): kept_shares[address] = os_token_holders.get(address, Wei(0)) # rabby doesnt support hoodi so skip api call @@ -326,8 +326,8 @@ async def get_kept_shares( # do not fetch data from api if all os token are in the wallet api_addresses = [] - for address in address_to_minted_shares.keys(): - if address_to_minted_shares[address] >= kept_shares[address]: + for address in address_to_redeemable_shares.keys(): + if address_to_redeemable_shares[address] >= kept_shares[address]: api_addresses.append(address) if not api_addresses: @@ -395,11 +395,11 @@ def create_os_token_positions( position_ltv: dict[tuple[ChecksumAddress, ChecksumAddress], float] = {} for allocator in allocators: allocator_kept_shares = kept_shares.get(allocator.address, Wei(0)) - redeemable_amount = max(0, allocator.total_shares - allocator_kept_shares) + redeemable_amount = max(0, allocator.total_redeemable_shares - allocator_kept_shares) if redeemable_amount == 0: continue - vault_ltv = {vs.address: vs.ltv for vs in allocator.vault_os_token_positions} + vault_ltv = {p.address: p.ltv for p in allocator.vault_os_token_positions} allocated_amount = 0 vaults_proportions = allocator.vaults_proportions.items() for index, (vault_address, proportion) in enumerate(vaults_proportions): diff --git a/src/redemptions/typings.py b/src/redemptions/typings.py index 10ce1ac5..2ec6b77b 100644 --- a/src/redemptions/typings.py +++ b/src/redemptions/typings.py @@ -25,12 +25,12 @@ class Allocator: vault_os_token_positions: list[VaultOsTokenPosition] @property - def total_shares(self) -> Wei: + def total_redeemable_shares(self) -> Wei: return Wei(sum(s.redeemable_shares for s in self.vault_os_token_positions)) @property def vaults_proportions(self) -> dict[ChecksumAddress, float]: - total = self.total_shares + total = self.total_redeemable_shares if total == 0: return {} return {s.address: s.redeemable_shares / total for s in self.vault_os_token_positions} From 3df35987610f03f1c9c4cfe0503168ad3457b1f7 Mon Sep 17 00:00:00 2001 From: Evgeny Gusarov Date: Wed, 19 Aug 2026 17:42:40 +0300 Subject: [PATCH 3/3] Store residual boosted shares on Allocator --- .../tests/test_update_redeemable_positions.py | 16 +++++++------- .../commands/update_redeemable_positions.py | 22 +++++++++---------- src/redemptions/typings.py | 2 ++ 3 files changed, 21 insertions(+), 19 deletions(-) diff --git a/src/redemptions/commands/tests/test_update_redeemable_positions.py b/src/redemptions/commands/tests/test_update_redeemable_positions.py index 63db111b..03a39208 100644 --- a/src/redemptions/commands/tests/test_update_redeemable_positions.py +++ b/src/redemptions/commands/tests/test_update_redeemable_positions.py @@ -328,7 +328,7 @@ def test_distributes_boosted_shares(): ) ] boost_ostoken_shares = {} - residual = _distribute_boosted_shares(allocators, boost_ostoken_shares) + _distribute_boosted_shares(allocators, boost_ostoken_shares) assert allocators == [ Allocator( address=address_1, @@ -338,7 +338,6 @@ def test_distributes_boosted_shares(): ], ) ] - assert residual == {} # basic reduction allocators = [ Allocator( @@ -361,7 +360,7 @@ def test_distributes_boosted_shares(): (address_2, vault_2): Wei(1500), } - residual = _distribute_boosted_shares(allocators, boost_ostoken_shares) + _distribute_boosted_shares(allocators, boost_ostoken_shares) assert allocators == [ Allocator( address=address_1, @@ -384,7 +383,7 @@ def test_distributes_boosted_shares(): ), ] assert [a.total_redeemable_shares for a in allocators] == [Wei(200), Wei(1000)] - assert residual == {} + assert [a.residual_boosted_shares for a in allocators] == [Wei(0), Wei(0)] def test_distributes_boosted_shares_cross_vault_residual(): @@ -404,16 +403,16 @@ def test_distributes_boosted_shares_cross_vault_residual(): ] boost_ostoken_shares = {(address_1, vault_2): Wei(400)} - residual = _distribute_boosted_shares(allocators, boost_ostoken_shares) + _distribute_boosted_shares(allocators, boost_ostoken_shares) assert allocators == [ Allocator( address=address_1, vault_os_token_positions=[ VaultOsTokenPosition(address=vault_1, minted_shares=Wei(1000), ltv=0.5), ], + residual_boosted_shares=Wei(400), ), ] - assert residual == {address_1: Wei(400)} def test_distributes_boosted_shares_excess_over_same_vault_mint_becomes_residual(): @@ -431,7 +430,7 @@ def test_distributes_boosted_shares_excess_over_same_vault_mint_becomes_residual ] boost_ostoken_shares = {(address_1, vault_1): Wei(500)} - residual = _distribute_boosted_shares(allocators, boost_ostoken_shares) + _distribute_boosted_shares(allocators, boost_ostoken_shares) assert allocators == [ Allocator( address=address_1, @@ -440,10 +439,11 @@ def test_distributes_boosted_shares_excess_over_same_vault_mint_becomes_residual address=vault_1, minted_shares=Wei(300), ltv=0.5, boosted_shares=Wei(300) ), ], + residual_boosted_shares=Wei(200), ), ] assert allocators[0].total_redeemable_shares == Wei(0) - assert residual == {address_1: Wei(200)} + assert allocators[0].residual_boosted_shares == Wei(200) @pytest.mark.usefixtures('_init_config') diff --git a/src/redemptions/commands/update_redeemable_positions.py b/src/redemptions/commands/update_redeemable_positions.py index 54cc23c1..9d8a2d1d 100644 --- a/src/redemptions/commands/update_redeemable_positions.py +++ b/src/redemptions/commands/update_redeemable_positions.py @@ -239,7 +239,7 @@ async def process( leverage_positions=leverage_positions, os_token_converter=os_token_converter, ) - residual_boosted_shares = _distribute_boosted_shares(allocators, boost_os_token_shares) + _distribute_boosted_shares(allocators, boost_os_token_shares) # filter zero positions. Filter before kept shares calculation to reduce api calls min_redeemable_shares = Web3.to_wei(min_os_token_position_amount_gwei, 'gwei') @@ -264,8 +264,10 @@ async def process( logger.info('Fetched kept tokens for %s addresses...', len(address_to_redeemable_shares)) # unmatched boosted shares are still held by the user, so keep them out of redeemable # amounts by treating them as kept. - for address, residual in residual_boosted_shares.items(): - kept_shares[address] = Wei(kept_shares[address] + residual) + for allocator in allocators: + kept_shares[allocator.address] = Wei( + kept_shares[allocator.address] + allocator.residual_boosted_shares + ) os_token_positions = create_os_token_positions(allocators, kept_shares, min_redeemable_shares) if not os_token_positions: @@ -436,14 +438,13 @@ def _save_positions_to_file(positions_payload: list[dict]) -> Path: def _distribute_boosted_shares( allocators: list[Allocator], boost_os_token_shares: dict[tuple[ChecksumAddress, ChecksumAddress], Wei], -) -> dict[ChecksumAddress, Wei]: +) -> None: """ osToken is fungible, so boosted shares aren't necessarily minted at the same vault the - leverage strategy borrows against. Match against the same-vault mint first; return - whatever can't be matched there as a per-user residual instead of dropping it. + leverage strategy borrows against. Match against the same-vault mint first; store + whatever can't be matched there as the allocator residual instead of dropping it. """ allocators_by_address = {a.address: a for a in allocators} - residual_boosted_shares: defaultdict[ChecksumAddress, Wei] = defaultdict(lambda: Wei(0)) for (user, vault), boosted_amount in boost_os_token_shares.items(): allocator = allocators_by_address.get(user) if allocator is None: @@ -453,10 +454,9 @@ def _distribute_boosted_shares( if vault_position: matched = min(vault_position.minted_shares, boosted_amount) vault_position.boosted_shares = matched - residual = Wei(boosted_amount - matched) - if residual: - residual_boosted_shares[user] = Wei(residual_boosted_shares[user] + residual) - return residual_boosted_shares + allocator.residual_boosted_shares = Wei( + allocator.residual_boosted_shares + boosted_amount - matched + ) async def _startup_check() -> None: diff --git a/src/redemptions/typings.py b/src/redemptions/typings.py index 2ec6b77b..a95acd56 100644 --- a/src/redemptions/typings.py +++ b/src/redemptions/typings.py @@ -23,6 +23,8 @@ def redeemable_shares(self) -> Wei: class Allocator: address: ChecksumAddress vault_os_token_positions: list[VaultOsTokenPosition] + # boosted shares that couldn't be matched against a same-vault mint + residual_boosted_shares: Wei = Wei(0) @property def total_redeemable_shares(self) -> Wei: