diff --git a/README.md b/README.md index 54278c9..118e4c1 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ of the wallet's first address.
All rewards for all wallets -Entire epoch. Daily SP, LP, governance rewards for 5 days: +Entire epoch. Daily SP, LP, governance and ROB rewards: ```console $ indy-rewards all 414 @@ -204,6 +204,35 @@ $ indy-rewards gov 415
+
+ROB (Redemption Orderbook) incentive rewards + +Rewards for Redemption Orderbook positions that are "in range" during an epoch. +Each epoch is split into 480 periods of 15 minutes, and INDY is distributed +pro-rata based on each owner's lovelaceAmount share per period. + +```console +$ indy-rewards rob 608 + Period Address Purpose Date Amount Expiration AvailableAt + 609 aada39748edc9f40ec53f879499a837f6badf180413fc03a7a345609 ROB reward for iUSD 2026-01-24 1.234567 2026-04-25 21:45 2026-01-24 23:00 + 609 6699280ab41b732e26e7d3cb02d57f61a76bc8e9a0ceccca4997b812 ROB reward for iUSD 2026-01-24 0.567890 2026-04-25 21:45 2026-01-24 23:00 + … +``` + +PKH filter also works: + +```console +$ indy-rewards rob --pkh aada 608 +``` + +File output: + +```console +$ indy-rewards rob --outfile rob-608.csv 608 +``` + +
+
LP token staking INDY APR diff --git a/indy_rewards/analytics_api/raw.py b/indy_rewards/analytics_api/raw.py index 9f3f8bd..f65fa39 100644 --- a/indy_rewards/analytics_api/raw.py +++ b/indy_rewards/analytics_api/raw.py @@ -5,6 +5,7 @@ import requests BASE_URL = "https://analytics.indigoprotocol.io/api" +BASE_URL_V1 = "https://analytics.indigoprotocol.io/api/v1" TIMEOUT = 20 # Seconds. @@ -428,3 +429,37 @@ def rewards_staking(snapshot_unix_time: float) -> list[dict]: ) response.raise_for_status() return response.json() + + +def redemption_orders( + at_unix_time: float, in_range: bool = True +) -> list[dict]: + """Redemption orderbook positions at a given time. + + Args: + at_unix_time: Unix time (in seconds) for the snapshot. + in_range: If True, only return orders that are "in range". + + Returns: + List of dicts, each representing a redemption order position. + + Dict structure (at minimum): + + owner (str): PaymentKeyHash of the owner, in hex. + lovelaceAmount (int): Amount in lovelaces for the position. + + Examples: + >>> orders = redemption_orders(1737752700) + >>> orders[0] + {'owner': '...', 'lovelaceAmount': 123456789, ...} + """ + params: dict[str, float | str] = {"timestamp": at_unix_time} + if in_range: + params["in_range"] = "true" + response = requests.get( + BASE_URL_V1 + "/rewards/redemption-orders", + params=params, + timeout=TIMEOUT, + ) + response.raise_for_status() + return response.json() diff --git a/indy_rewards/cli.py b/indy_rewards/cli.py index 73ca847..ecbcfca 100644 --- a/indy_rewards/cli.py +++ b/indy_rewards/cli.py @@ -10,6 +10,7 @@ from indy_rewards import gov as gov_module from indy_rewards import lp as lp_module from indy_rewards import polygon_api +from indy_rewards import rob as rob_module from indy_rewards import sp as sp_module from indy_rewards import summary, time_utils, volatility from indy_rewards.models import ( @@ -206,6 +207,21 @@ def gov(indy: float, pkh: tuple[str], outfile: str, epoch: int): _output(rewards, outfile) +@rewards.command() +@pkh_option +@outfile_option +@click.argument("epoch", type=int) +def rob(pkh: tuple[str], outfile: str, epoch: int): + """Print or save Redemption Orderbook (ROB) incentive rewards. + + EPOCH: Epoch to get rewards for. + """ + _error_on_future(epoch) + rewards = rob_module.get_epoch_rewards_per_staker(epoch, config.rob_epoch_emission(epoch)) + rewards = _pkh_filter(rewards, pkh) + _output(rewards, outfile) + + @rewards.command() @sp_indy_option() @pkh_option @@ -256,7 +272,7 @@ def sp_apr(indy: float, epoch_or_date: int | datetime.date): @outfile_option @epoch_or_date_arg def all(pkh: tuple[str], outfile: str, epoch_or_date: int | datetime.date): - """Print or save SP, LP and governance staking rewards.""" + """Print or save SP, LP, governance and ROB staking rewards.""" _load_polygon_api_key_or_fail(epoch_or_date) if isinstance(epoch_or_date, int): @@ -265,13 +281,16 @@ def all(pkh: tuple[str], outfile: str, epoch_or_date: int | datetime.date): sp_epoch_emission(epoch_or_date), config.LP_EPOCH_INDY, gov_epoch_emission(epoch_or_date), + config.rob_epoch_emission(epoch_or_date), ) else: + _epoch = time_utils.date_to_epoch(epoch_or_date) rewards = summary.get_day_all_rewards( epoch_or_date, - sp_epoch_emission(time_utils.date_to_epoch(epoch_or_date)), + sp_epoch_emission(_epoch), config.LP_EPOCH_INDY, - gov_epoch_emission(time_utils.date_to_epoch(epoch_or_date)), + gov_epoch_emission(_epoch), + config.rob_epoch_emission(_epoch), ) rewards = _pkh_filter(rewards, pkh) @@ -314,6 +333,7 @@ def summary_command( sp_indy, lp_indy, gov_indy, + config.rob_epoch_emission(epoch_or_date), ) epoch_rewards = _pkh_filter(epoch_rewards, pkh) sum_table = summary.get_summary(epoch_rewards) @@ -323,7 +343,11 @@ def summary_command( if gov_indy == -1: gov_indy = gov_epoch_emission(time_utils.date_to_epoch(epoch_or_date)) day_rewards = summary.get_day_all_rewards( - epoch_or_date, sp_indy, lp_indy, gov_indy + epoch_or_date, + sp_indy, + lp_indy, + gov_indy, + config.rob_epoch_emission(time_utils.date_to_epoch(epoch_or_date)), ) day_rewards = _pkh_filter(day_rewards, pkh) sum_table = summary.get_summary(day_rewards) diff --git a/indy_rewards/config.py b/indy_rewards/config.py index e9ce4e8..492ef4f 100644 --- a/indy_rewards/config.py +++ b/indy_rewards/config.py @@ -6,6 +6,20 @@ LP_EPOCH_INDY: Final[int] = 4795 +ROB_EPOCH_INDY: dict[IAsset, float] = { + IAsset.iUSD: 500.0, + IAsset.iBTC: 0.0, + IAsset.iETH: 0.0, + IAsset.iSOL: 0.0, +} + + +def rob_epoch_emission(epoch: int) -> dict[IAsset, float]: + """Get ROB INDY emission amounts per iAsset for a given epoch.""" + if epoch >= 608: + return ROB_EPOCH_INDY + return {} + IASSET_LAUNCH_DATES = { IAsset.iUSD: datetime.date(2022, 11, 21), # Epoch 377's first day. IAsset.iBTC: datetime.date(2022, 11, 21), diff --git a/indy_rewards/rob/__init__.py b/indy_rewards/rob/__init__.py new file mode 100644 index 0000000..fa6fb66 --- /dev/null +++ b/indy_rewards/rob/__init__.py @@ -0,0 +1 @@ +from .distribution import get_epoch_rewards_per_staker diff --git a/indy_rewards/rob/distribution.py b/indy_rewards/rob/distribution.py new file mode 100644 index 0000000..0de3069 --- /dev/null +++ b/indy_rewards/rob/distribution.py @@ -0,0 +1,137 @@ +"""Redemption Orderbook (ROB) incentive reward distribution. + +Distributes INDY to owners with in-range redemption orderbook positions. +Each epoch is divided into 480 periods of 15 minutes (900 seconds). +For each period, INDY is distributed pro-rata based on each owner's share +of total lovelaceAmount across all in-range positions. +""" + +from collections import defaultdict +from concurrent.futures import ThreadPoolExecutor, as_completed + +from .. import analytics_api, time_utils +from ..models import IAsset, IndividualReward + + +NUM_PERIODS = 480 +PERIOD_SECONDS = 900 # 15 minutes +MAX_WORKERS = 20 + + +def get_epoch_rewards_per_staker( + epoch: int, rob_indy_per_iasset: dict[IAsset, float] +) -> list[IndividualReward]: + """Get individual ROB INDY rewards for an epoch. + + Fetches all 480 periods once, then distributes INDY per iAsset from + the same data. This avoids redundant API calls when multiple iAssets + have non-zero emissions. + + Args: + epoch: Epoch to calculate rewards for. + rob_indy_per_iasset: INDY amount to distribute per iAsset for the epoch. + E.g. {IAsset.iUSD: 500.0, IAsset.iBTC: 0.0, ...} + + Returns: + List of IndividualReward, one per owner per iAsset (aggregated across + all 480 periods). + """ + # Filter to only iAssets with non-zero INDY + active_iassets = { + iasset: indy for iasset, indy in rob_indy_per_iasset.items() if indy > 0 + } + if not active_iassets: + return [] + + epoch_start_date = time_utils.get_epoch_start_date(epoch) + epoch_start_unix = time_utils.get_snapshot_unix_time(epoch_start_date) + epoch_end_date = time_utils.get_epoch_end_date(epoch) + + # Fetch all 480 periods once + all_period_orders = _fetch_all_periods(epoch_start_unix) + + rewards: list[IndividualReward] = [] + + for iasset, epoch_indy in active_iassets.items(): + owner_totals = _distribute_for_asset( + all_period_orders, epoch_indy, iasset.name + ) + + for owner, total_indy in owner_totals.items(): + rewards.append( + IndividualReward( + indy=total_indy, + day=epoch_end_date, + pkh=owner, + expiration=time_utils.get_reward_expiration(epoch_end_date), + description=f"ROB reward for {iasset.name}", + ) + ) + + return rewards + + +def _fetch_orders(timestamp: float) -> list[dict]: + """Fetch redemption orders for a single timestamp.""" + return analytics_api.raw.redemption_orders(timestamp, in_range=True) + + +def _fetch_all_periods(epoch_start_unix: float) -> list[list[dict]]: + """Fetch redemption orders for all 480 periods in parallel. + + Returns: + List of 480 order lists (one per period). + """ + timestamps = [ + epoch_start_unix + (i * PERIOD_SECONDS) for i in range(NUM_PERIODS) + ] + + # Use dict to preserve period ordering by timestamp + results: dict[float, list[dict]] = {} + + with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: + future_to_ts = { + executor.submit(_fetch_orders, ts): ts for ts in timestamps + } + for future in as_completed(future_to_ts): + ts = future_to_ts[future] + results[ts] = future.result() + + return [results[ts] for ts in timestamps] + + +def _distribute_for_asset( + all_period_orders: list[list[dict]], epoch_indy: float, asset_name: str +) -> dict[str, float]: + """Distribute INDY for a single asset across all periods. + + Args: + all_period_orders: Pre-fetched orders for all 480 periods. + epoch_indy: Total INDY to distribute for this iAsset this epoch. + asset_name: Only include orders matching this asset (e.g. "iUSD"). + + Returns: + Dict mapping owner PKH to total INDY earned across all periods. + """ + indy_per_period = epoch_indy / NUM_PERIODS + owner_totals: dict[str, float] = defaultdict(float) + + for orders in all_period_orders: + if not orders: + continue + + # Filter by asset and group by owner, summing lovelaceAmount + owner_amounts: dict[str, int] = defaultdict(int) + for order in orders: + if order["asset"] == asset_name: + owner_amounts[order["owner"]] += order["lovelaceAmount"] + + total_amount = sum(owner_amounts.values()) + if total_amount == 0: + continue + + # Distribute pro-rata + for owner, amount in owner_amounts.items(): + owner_totals[owner] += indy_per_period * amount / total_amount + + return dict(owner_totals) diff --git a/indy_rewards/summary.py b/indy_rewards/summary.py index 89d2b0d..9a1c3eb 100644 --- a/indy_rewards/summary.py +++ b/indy_rewards/summary.py @@ -2,8 +2,8 @@ import pandas as pd -from . import gov, lp, sp, time_utils -from .models import IndividualReward +from . import config, gov, lp, rob, sp, time_utils +from .models import IAsset, IndividualReward def get_epoch_all_rewards( @@ -11,14 +11,17 @@ def get_epoch_all_rewards( sp_indy: float, lp_indy: float, gov_indy: float, + rob_indy: dict[IAsset, float] | None = None, ) -> list[IndividualReward]: - """Returns list of SP, LP and gov INDY rewards for accounts for an epoch. + """Returns list of SP, LP, gov and ROB INDY rewards for accounts for an epoch. Args: epoch: Epoch to calculate rewards for. sp_indy: INDY amount to distribute to SP stakers. E.g. 28768. lp_indy: INDY to distribute to LP stakers. gov_indy: INDY to distribute to INDY governance stakers. + rob_indy: INDY amounts per iAsset to distribute to ROB position holders. + If None, uses config.rob_epoch_emission(epoch). Returns: Pandas DataFrame with columns: @@ -31,10 +34,14 @@ def get_epoch_all_rewards( - Expiration: Date and time after which this reward is no longer claimable, e.g. 2023-06-20 21:45. """ + if rob_indy is None: + rob_indy = config.rob_epoch_emission(epoch) + gov_rewards = gov.get_epoch_rewards_per_staker(epoch, gov_indy) sp_rewards = sp.get_epoch_rewards_per_staker(epoch, sp_indy) + rob_rewards = rob.get_epoch_rewards_per_staker(epoch, rob_indy) - all_rewards = sp_rewards + gov_rewards + all_rewards = sp_rewards + gov_rewards + rob_rewards if epoch < 422: all_rewards += lp.get_epoch_rewards_per_staker(epoch, lp_indy) @@ -47,13 +54,18 @@ def get_day_all_rewards( sp_indy_per_epoch: float, lp_indy_per_epoch: float, gov_indy_per_epoch: float, + rob_indy: dict[IAsset, float] | None = None, ) -> list[IndividualReward]: + if rob_indy is None: + rob_indy = config.rob_epoch_emission(time_utils.date_to_epoch(day)) + rewards = [] epoch = time_utils.date_to_epoch(day) epoch_end_date = time_utils.get_epoch_end_date(epoch) if epoch_end_date == day: rewards += gov.get_epoch_rewards_per_staker(epoch, gov_indy_per_epoch) + rewards += rob.get_epoch_rewards_per_staker(epoch, rob_indy) rewards += sp.get_rewards_per_staker(day, sp_indy_per_epoch) if day <= datetime.date(2023, 7, 4): @@ -127,5 +139,7 @@ def _split_purpose(purpose: str) -> tuple[str, ...] | tuple[str, None]: return ("LP reward", split[3] + " on " + split[-1]) elif purpose.startswith("SP reward for "): return ("SP reward", purpose.removeprefix("SP reward for ")) + elif purpose.startswith("ROB reward for "): + return ("ROB reward", purpose.removeprefix("ROB reward for ")) else: return (purpose, None) diff --git a/indy_rewards/time_utils.py b/indy_rewards/time_utils.py index bcd910d..93ae316 100644 --- a/indy_rewards/time_utils.py +++ b/indy_rewards/time_utils.py @@ -23,6 +23,11 @@ def date_to_epoch(date: datetime.date) -> int: return epoch +def get_epoch_start_date(epoch: int) -> datetime.date: + """Get UTC date of the epoch's first block (= end of previous epoch).""" + return get_epoch_end_date(epoch - 1) + + def get_epoch_end_date(epoch: int) -> datetime.date: """Get UTC date of the epoch's last block.""" days_from_ref = (epoch + 1) * 5 diff --git a/tests/test_summary.py b/tests/test_summary.py index ddccde5..b10af2c 100644 --- a/tests/test_summary.py +++ b/tests/test_summary.py @@ -180,6 +180,10 @@ def mock_summary_dependencies(mocker: pytest_mock.MockerFixture): "indy_rewards.summary.gov.get_epoch_rewards_per_staker", wraps=mocked_gov_distribution, ) + mocker.patch( + "indy_rewards.summary.rob.get_epoch_rewards_per_staker", + return_value=[], + ) def get_expiration(