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
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ of the wallet's first address.
<details>
<summary>All rewards for all wallets</summary>

Entire epoch. Daily SP, LP, governance rewards for 5 days:
Entire epoch. Daily SP, LP, governance and ROB rewards:

```console
$ indy-rewards all 414
Expand Down Expand Up @@ -204,6 +204,35 @@ $ indy-rewards gov 415

</details>

<details>
<summary>ROB (Redemption Orderbook) incentive rewards</summary>

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
```

</details>

<details>
<summary>LP token staking INDY APR</summary>

Expand Down
35 changes: 35 additions & 0 deletions indy_rewards/analytics_api/raw.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.


Expand Down Expand Up @@ -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()
32 changes: 28 additions & 4 deletions indy_rewards/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions indy_rewards/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions indy_rewards/rob/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .distribution import get_epoch_rewards_per_staker
137 changes: 137 additions & 0 deletions indy_rewards/rob/distribution.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading