Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 31 additions & 21 deletions src/redemptions/commands/tests/test_update_redeemable_positions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -328,8 +328,8 @@ def test_reduces_boosted_amount():
)
]
boost_ostoken_shares = {}
result, residual = _reduce_boosted_amount(allocators, boost_ostoken_shares)
assert result == [
_distribute_boosted_shares(allocators, boost_ostoken_shares)
assert allocators == [
Allocator(
address=address_1,
vault_os_token_positions=[
Expand All @@ -338,7 +338,6 @@ def test_reduces_boosted_amount():
],
)
]
assert residual == {}
# basic reduction
allocators = [
Allocator(
Expand All @@ -361,26 +360,33 @@ def test_reduces_boosted_amount():
(address_2, vault_2): Wei(1500),
}

result, residual = _reduce_boosted_amount(allocators, boost_ostoken_shares)
assert result == [
_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 residual == {}
assert [a.total_redeemable_shares for a in allocators] == [Wei(200), Wei(1000)]
assert [a.residual_boosted_shares for a in allocators] == [Wei(0), Wei(0)]


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()
Expand All @@ -397,19 +403,19 @@ 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 == [
_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_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()

Expand All @@ -424,16 +430,20 @@ 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 == [
_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)
),
],
residual_boosted_shares=Wei(200),
),
]
assert residual == {address_1: Wei(200)}
assert allocators[0].total_redeemable_shares == Wei(0)
assert allocators[0].residual_boosted_shares == Wei(200)


@pytest.mark.usefixtures('_init_config')
Expand Down
67 changes: 34 additions & 33 deletions src/redemptions/commands/update_redeemable_positions.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,35 +239,37 @@ 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)
_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
vault_position
for vault_position in allocator.vault_os_token_positions
if vault_position.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))
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():
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_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
Expand Down Expand Up @@ -308,7 +310,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]:
Expand All @@ -317,7 +319,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
Expand All @@ -326,8 +328,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:
Expand Down Expand Up @@ -385,7 +387,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.
Expand All @@ -395,11 +397,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):
Expand All @@ -409,7 +411,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(
Expand All @@ -433,29 +435,28 @@ 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]]:
) -> 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:
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)
residual = Wei(boosted_amount - matched)
if residual:
residual_boosted_shares[user] = Wei(residual_boosted_shares[user] + residual)
return allocators, residual_boosted_shares
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
allocator.residual_boosted_shares = Wei(
allocator.residual_boosted_shares + boosted_amount - matched
)


async def _startup_check() -> None:
Expand Down
16 changes: 12 additions & 4 deletions src/redemptions/typings.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,31 @@ 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
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_shares(self) -> Wei:
return Wei(sum(s.minted_shares for s in self.vault_os_token_positions))
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.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)
Expand Down
Loading