Skip to content
Open
Show file tree
Hide file tree
Changes from 69 commits
Commits
Show all changes
75 commits
Select commit Hold shift + click to select a range
7e82e70
Add update redeemable positions command
cyc60 Jan 8, 2026
488c6bc
Cleanup
cyc60 Jan 8, 2026
fac0134
Update api client
cyc60 Jan 12, 2026
cc6010f
Check arbitrum balance, add min shares arg
cyc60 Jan 12, 2026
743f179
Check boosted shares via subgraph
cyc60 Jan 12, 2026
f314a01
Add vault proportions
cyc60 Jan 13, 2026
096b2ea
Merge branch 'master' into redeemable-positions
cyc60 Jan 13, 2026
bf5d407
Add logs
cyc60 Jan 13, 2026
baf4d2f
Small refactoring
cyc60 Jan 13, 2026
6c85b19
Add API rate limits
cyc60 Jan 13, 2026
503c959
Update comment
cyc60 Jan 13, 2026
eb4f195
Merge branch 'master' into redeemable-positions
cyc60 Jan 13, 2026
6600e0e
Rename var
cyc60 Jan 13, 2026
14465fc
Fix typo
cyc60 Jan 13, 2026
8437f4b
Merge branch 'master' into redeemable-positions
tsudmi Jan 19, 2026
248a9be
Add tests
cyc60 Jan 19, 2026
5d4eea4
Review fixes
cyc60 Jan 19, 2026
dc38e91
Review fixes #2
cyc60 Jan 19, 2026
73899a3
Merge branch 'master' into redeemable-positions
cyc60 Jan 19, 2026
89bd7fa
Review fixes #3
cyc60 Jan 19, 2026
cb7308d
Add merkle root
cyc60 Jan 19, 2026
625c0ba
Add progress bar
cyc60 Jan 19, 2026
c455880
Simplify boosted filters
cyc60 Jan 20, 2026
1e861b2
Fetch oseth from graph
cyc60 Jan 20, 2026
06e8f81
Add OsTokenConverter
cyc60 Jan 20, 2026
32546bb
Add tests
cyc60 Jan 20, 2026
921dcd2
Install multiproof
cyc60 Jan 21, 2026
ff10073
Add process-redeemer command
cyc60 Jan 22, 2026
1e71e0f
Review fixes
cyc60 Jan 22, 2026
93b2f65
Rm OS_TOKEN_VAULT_CONTROLLER_CONTRACT_ADDRESS
cyc60 Jan 22, 2026
6d6b0fc
Add integration test for update_redeemable_positions
cyc60 Jan 22, 2026
dc7f915
Merge branch 'redeemable-positions' into process-redeemer
cyc60 Jan 22, 2026
0ec716f
Add integration test for update_redeemable_positions
cyc60 Jan 26, 2026
52f2ce1
Merge branch 'redeemable-positions' into process-redeemer
cyc60 Jan 26, 2026
5b266cd
Rename package
cyc60 Jan 26, 2026
0f689b0
Merge branch 'redeemable-positions' into process-redeemer
cyc60 Jan 26, 2026
526af08
Metavault handling
cyc60 Jan 27, 2026
06d7c71
Update tests
cyc60 Jan 27, 2026
27cb7c5
Add proofs
cyc60 Jan 27, 2026
69e0b73
Fix IPFS mock
cyc60 Jan 28, 2026
2dff8eb
Move log
cyc60 Jan 28, 2026
5d9a0ff
Copilot fixes
cyc60 Jan 28, 2026
bfb8866
Add withdrawals to cover redemption assets
evgeny-stakewise Jan 25, 2026
8c6f6d2
Add tests
evgeny-stakewise Feb 3, 2026
fd0ca47
Merge branch 'cover-redeemer-queue-3' into process-redeemer
cyc60 Feb 4, 2026
1838d66
Unify is_meta_vault
cyc60 Feb 4, 2026
079cc80
Merge branch 'v5-release' into process-redeemer
cyc60 Feb 17, 2026
211e09e
Unify redeemable positions fetch
cyc60 Feb 18, 2026
3d7a509
Fix contract calls
cyc60 Feb 18, 2026
8087439
Check empty merkle root
cyc60 Feb 18, 2026
0e8e1db
Skip empty positions
cyc60 Feb 18, 2026
9490f74
Refactoring
cyc60 Feb 18, 2026
8b2014c
Add tests
cyc60 Feb 18, 2026
2eb4378
Add interval option
cyc60 Feb 18, 2026
44dac79
Remove unused options
cyc60 Feb 19, 2026
5c116cb
Split OsTokenPosition.redeemable_shares into available_shares and sha…
cyc60 Feb 19, 2026
97b3b06
Update tests
cyc60 Feb 20, 2026
8bef13b
Merge branch 'v5-release' into process-redeemer
cyc60 Mar 5, 2026
03102ed
Use sw utils
cyc60 Mar 5, 2026
774c62b
Fix redeemable_assets filter
cyc60 Mar 5, 2026
f28c964
Extract fetch_harvest_params_by_vault from select_positions
cyc60 Mar 5, 2026
f4874ac
Refactoring
cyc60 Mar 6, 2026
a5efdbe
Add get_multiple_harvest_params
cyc60 Mar 6, 2026
bb35ee1
Fix nonce
cyc60 Mar 6, 2026
c434029
Refactor
cyc60 Mar 6, 2026
37dae63
Add tests
cyc60 Mar 6, 2026
acf74df
Refactor get_multiple_harvest_params
cyc60 Mar 6, 2026
22e846c
Single _try_redeem_meta_vault call
cyc60 Mar 9, 2026
1899d73
Refactoring
cyc60 Mar 9, 2026
1f32663
Handle zero nonce
cyc60 Mar 9, 2026
8c97ae3
Fix tests
cyc60 Mar 9, 2026
88dcdfb
Review fixes #1
cyc60 Mar 10, 2026
8c2a97c
Add min-queued-assets option
cyc60 Mar 10, 2026
1d8c5d9
Update tests
cyc60 Mar 10, 2026
a4d4608
Rm double comparison
cyc60 Mar 10, 2026
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
501 changes: 501 additions & 0 deletions src/commands/internal/process_redeemer.py

Large diffs are not rendered by default.

611 changes: 611 additions & 0 deletions src/commands/tests/test_internal/test_process_redeemer.py

Large diffs are not rendered by default.

30 changes: 30 additions & 0 deletions src/common/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,36 @@ async def get_exit_queue_missing_assets(
block_identifier=block_number
)

async def positions_manager(self) -> ChecksumAddress:
return await self.contract.functions.positionsManager().call()

async def queued_shares(self, block_number: BlockNumber | None = None) -> Wei:
return await self.contract.functions.queuedShares().call(block_identifier=block_number)

async def can_process_exit_queue(self, block_number: BlockNumber | None = None) -> bool:
return await self.contract.functions.canProcessExitQueue().call(
block_identifier=block_number
)

async def process_exit_queue(self) -> HexStr:
tx_function = self.contract.functions.processExitQueue()
tx_hash = await transaction_gas_wrapper(tx_function)
return Web3.to_hex(tx_hash)

async def redeem_sub_vaults_assets(
self, vault_address: ChecksumAddress, assets_to_redeem: Wei
) -> HexStr:
tx_function = self.contract.functions.redeemSubVaultsAssets(vault_address, assets_to_redeem)
tx_hash = await transaction_gas_wrapper(tx_function)
tx_receipt = await self.execution_client.eth.wait_for_transaction_receipt(
tx_hash, timeout=settings.execution_transaction_timeout
)
if not tx_receipt['status']:
raise RuntimeError(
f'redeemSubVaultsAssets transaction failed. Tx Hash: {Web3.to_hex(tx_hash)}'
)
return Web3.to_hex(tx_hash)


class ValidatorsCheckerContract(ContractWrapper):
abi_path = 'abi/IValidatorsChecker.json'
Expand Down
61 changes: 45 additions & 16 deletions src/common/harvest.py
Original file line number Diff line number Diff line change
@@ -1,38 +1,67 @@
from typing import cast

from hexbytes import HexBytes
from sw_utils.networks import GNO_NETWORKS
from web3 import Web3
from web3.types import BlockNumber, Wei
from web3.types import BlockNumber, ChecksumAddress, Wei

from src.common.clients import ipfs_fetch_client
from src.common.contracts import VaultContract, keeper_contract
from src.common.typings import HarvestParams
from src.config.settings import settings


async def get_harvest_params(block_number: BlockNumber | None = None) -> HarvestParams | None:
if not await keeper_contract.can_harvest(settings.vault, block_number):
return None
async def get_harvest_params(
vault: ChecksumAddress, block_number: BlockNumber | None = None
) -> HarvestParams | None:
"""Get harvest params for a single vault."""
result = await get_multiple_harvest_params([vault], block_number)
return result[vault]


async def get_multiple_harvest_params(
vaults: list[ChecksumAddress], block_number: BlockNumber | None = None
) -> dict[ChecksumAddress, HarvestParams | None]:
"""Get harvest params for multiple vaults.

Checks can_harvest for all vaults first, then fetches IPFS data only
if at least one vault is harvestable.
"""
if not vaults:
return {}

last_rewards = await keeper_contract.get_last_rewards_update(block_number)
if last_rewards is None:
return None
return {vault: None for vault in vaults}
Comment on lines +25 to +35
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring says can_harvest is checked for all vaults before fetching remote data, but the implementation calls keeper_contract.get_last_rewards_update() first. This adds an extra (potentially expensive) chain log lookup even when no vault is harvestable. Consider checking can_harvest for the provided vaults first and only then fetching last rewards/IPFS (or update the docstring if the order is intentional).

Copilot uses AI. Check for mistakes.

harvestable_vaults: list[ChecksumAddress] = [
vault for vault in vaults if await keeper_contract.can_harvest(vault, block_number)
]

if not harvestable_vaults:
return {vault: None for vault in vaults}

Comment on lines 33 to +43
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_multiple_harvest_params calls keeper_contract.can_harvest(...) sequentially for each vault (await inside the list comprehension). For many vaults (as in the new redeemer flow) this adds avoidable latency. Consider issuing these calls concurrently with asyncio.gather and then filtering based on the results.

Copilot uses AI. Check for mistakes.
ipfs_data = cast(dict, await ipfs_fetch_client.fetch_json(last_rewards.ipfs_hash))

results: dict[ChecksumAddress, HarvestParams | None] = {vault: None for vault in vaults}
for vault in harvestable_vaults:
vault_contract = VaultContract(vault)
results[vault] = await _extract_harvest_params(
vault_contract=vault_contract,
ipfs_data=ipfs_data,
rewards_root=last_rewards.rewards_root,
)

vault_contract = VaultContract(settings.vault)
harvest_params = await _fetch_harvest_params_from_ipfs(
vault_contract=vault_contract,
ipfs_hash=last_rewards.ipfs_hash,
rewards_root=last_rewards.rewards_root,
)
return harvest_params
return results


async def _fetch_harvest_params_from_ipfs(
vault_contract: VaultContract, ipfs_hash: str, rewards_root: bytes
async def _extract_harvest_params(
vault_contract: VaultContract, ipfs_data: dict, rewards_root: bytes
) -> HarvestParams | None:
ipfs_data = await ipfs_fetch_client.fetch_json(ipfs_hash)
"""Extract harvest params for a single vault from pre-fetched IPFS data."""
mev_escrow = await vault_contract.mev_escrow()

for vault_data in ipfs_data['vaults']: # type: ignore
for vault_data in ipfs_data['vaults']:
if vault_contract.contract_address != Web3.to_checksum_address(vault_data['vault']):
continue

Expand Down
6 changes: 4 additions & 2 deletions src/common/startup_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -495,8 +495,10 @@ async def _check_events_logs() -> None:


async def _check_vault_withdrawable_assets() -> None:
harvest_params = await get_harvest_params()
withdrawable_assets = await get_withdrawable_assets(harvest_params=harvest_params)
harvest_params = await get_harvest_params(settings.vault)
withdrawable_assets = await get_withdrawable_assets(
settings.vault, harvest_params=harvest_params
)

# Note. We round down assets in the log message because of the case when assets
# is slightly less than required amount to register validator.
Expand Down
38 changes: 38 additions & 0 deletions src/common/tests/test_harvest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from unittest.mock import AsyncMock, MagicMock, patch

from eth_typing import BlockNumber
from web3 import Web3

from src.common.harvest import get_multiple_harvest_params

HARVEST_MODULE = 'src.common.harvest'

VAULT_1 = Web3.to_checksum_address('0x' + '11' * 20)
VAULT_2 = Web3.to_checksum_address('0x' + '22' * 20)
OWNER_1 = Web3.to_checksum_address('0x' + '33' * 20)
OWNER_2 = Web3.to_checksum_address('0x' + '44' * 20)


class TestGetMultipleHarvestParams:
async def test_empty_vaults(self) -> None:
result = await get_multiple_harvest_params([], BlockNumber(100))
assert result == {}

async def test_no_last_rewards(self) -> None:
with patch(f'{HARVEST_MODULE}.keeper_contract') as mock_keeper:
mock_keeper.get_last_rewards_update = AsyncMock(return_value=None)
result = await get_multiple_harvest_params([VAULT_1], BlockNumber(100))
assert result == {VAULT_1: None}

async def test_cannot_harvest(self) -> None:
mock_last_rewards = MagicMock()
mock_last_rewards.ipfs_hash = 'QmTest'
with (
patch(f'{HARVEST_MODULE}.keeper_contract') as mock_keeper,
patch(f'{HARVEST_MODULE}.ipfs_fetch_client') as mock_ipfs,
):
mock_keeper.get_last_rewards_update = AsyncMock(return_value=mock_last_rewards)
mock_keeper.can_harvest = AsyncMock(return_value=False)
mock_ipfs.fetch_json = AsyncMock(return_value={})
result = await get_multiple_harvest_params([VAULT_1, VAULT_2], BlockNumber(100))
assert result == {VAULT_1: None, VAULT_2: None}
2 changes: 1 addition & 1 deletion src/harvest/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ async def process_block(self, interrupt_handler: InterruptHandler) -> None:
"""

# check current gas prices
harvest_params = await get_harvest_params()
harvest_params = await get_harvest_params(settings.vault)
if not harvest_params:
return

Expand Down
2 changes: 2 additions & 0 deletions src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from src.commands.create_wallet import create_wallet
from src.commands.exit_validators import exit_validators
from src.commands.init import init
from src.commands.internal.process_redeemer import process_redeemer
from src.commands.internal.update_redeemable_positions import (
update_redeemable_positions,
)
Expand Down Expand Up @@ -58,6 +59,7 @@ def cli() -> None:
cli.add_command(node_start)
cli.add_command(node_status)
cli.add_command(update_redeemable_positions)
cli.add_command(process_redeemer)


if __name__ == '__main__':
Expand Down
10 changes: 7 additions & 3 deletions src/redemptions/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@


batch_size = 20
ZERO_MERKLE_ROOT = HexStr('0x' + '0' * 64)


async def get_redemption_assets(chain_head: ChainHead) -> Wei:
Expand Down Expand Up @@ -87,7 +88,9 @@ async def aggregate_redemption_assets_by_vaults(
os_token_converter = await create_os_token_converter(block_number)
total_redemption_shares = os_token_converter.to_shares(total_redemption_assets)

nonce = await os_token_redeemer_contract.nonce(block_number=block_number)
# The contract increments nonce during setRedeemablePositions,
# but uses nonce - 1 for leaf hash computation during redemption.
tree_nonce = await os_token_redeemer_contract.nonce(block_number) - 1
vault_to_unprocessed_shares: defaultdict[ChecksumAddress, Wei] = defaultdict(lambda: Wei(0))

# Iterate through redeemable positions until total redemption shares are exhausted
Expand All @@ -96,7 +99,7 @@ async def aggregate_redemption_assets_by_vaults(
):
processed_shares_batch = await get_processed_shares_batch(
os_token_positions_batch=os_token_position_batch,
nonce=nonce,
nonce=tree_nonce,
block_number=block_number,
)
for os_token_position, processed_shares in zip(
Expand Down Expand Up @@ -143,7 +146,8 @@ async def iter_os_token_positions(
# Check whether redeemable positions are available
if not redeemable_positions.ipfs_hash:
return

if redeemable_positions.merkle_root == ZERO_MERKLE_ROOT:
return
# Fetch redeemable positions data from IPFS
data = cast(list[dict], await ipfs_fetch_client.fetch_json(redeemable_positions.ipfs_hash))

Expand Down
2 changes: 2 additions & 0 deletions src/redemptions/typings.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ class OsTokenPosition:
owner: ChecksumAddress
vault: ChecksumAddress
amount: Wei
available_shares: Wei = Wei(0)
shares_to_redeem: Wei = Wei(0)

def as_dict(self) -> dict:
return {
Expand Down
2 changes: 1 addition & 1 deletion src/reward_splitter/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ async def process_block(self, interrupt_handler: InterruptHandler) -> None:
'Processing fee splitter %s ',
reward_splitter.address,
)
harvest_params = await get_harvest_params()
harvest_params = await get_harvest_params(settings.vault)
exit_requests = splitter_to_exit_requests.get(reward_splitter.address, []) # nosec

reward_splitter_calls = await _get_reward_splitter_calls(
Expand Down
6 changes: 4 additions & 2 deletions src/validators/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,9 +206,11 @@ def process_network_validator_event(
return None


async def get_withdrawable_assets(harvest_params: HarvestParams | None) -> Wei:
async def get_withdrawable_assets(
vault: ChecksumAddress, harvest_params: HarvestParams | None
) -> Wei:
"""Fetches vault's available assets for staking."""
vault_contract = VaultContract(settings.vault)
vault_contract = VaultContract(vault)
if harvest_params is None:
return await vault_contract.functions.withdrawableAssets().call()

Expand Down
4 changes: 2 additions & 2 deletions src/validators/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ async def process(self) -> None:
await update_unused_validator_keys_metric(
keystore=self.keystore,
)
harvest_params = await get_harvest_params()
harvest_params = await get_harvest_params(settings.vault)

vault_assets = await get_vault_assets(harvest_params=harvest_params)
vault_assets = Gwei(max(0, vault_assets - settings.vault_min_balance_gwei))
Expand Down Expand Up @@ -262,7 +262,7 @@ async def register_new_validators(


async def get_vault_assets(harvest_params: HarvestParams | None) -> Gwei:
vault_assets = await get_withdrawable_assets(harvest_params=harvest_params)
vault_assets = await get_withdrawable_assets(settings.vault, harvest_params=harvest_params)
if settings.network in GNO_NETWORKS:
# apply GNO -> mGNO exchange rate
vault_assets = convert_to_mgno(vault_assets)
Expand Down
2 changes: 1 addition & 1 deletion src/withdrawals/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ async def get_queued_assets(
Get exit queue missing assets.
For Gno networks return value in mGNO-Gwei.
"""
harvest_params = await get_harvest_params(chain_head.block_number)
harvest_params = await get_harvest_params(settings.vault, chain_head.block_number)

# Get exit queue cumulative tickets
exit_queue_cumulative_ticket = (
Expand Down
Loading