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(