Overview
For tax reporting, you need per-epoch reward data to calculate the fiat value of rewards at the time they were received. This requires iterating through epochs using the Custom Range Rewards approach.
API Endpoint: This guide uses /api/v2/ethereum/validators/rewards-list for per-epoch reward data required for tax calculations.
Disclaimer: This guide provides technical instructions for fetching reward data. It is not tax advice. Consult a qualified tax professional for guidance on how staking rewards should be reported in your jurisdiction.
Why Per-Epoch Data is Required
Most tax jurisdictions require you to report the fiat value of staking rewards at the time they were received. This means you need:
- Reward amount for each epoch (or day)
- Historical ETH-USD price at that time
- Calculation:
fiat_value = reward_eth × price_at_time
The all_time or rolling period windows from Rewards Aggregated only provide a total sum—they cannot be used for tax calculations because you need per-epoch data to match with historical prices.
Tax Year Epoch Boundaries
First, calculate which epochs correspond to your tax year in your local time zone:
from datetime import datetime, timezone, timedelta
GENESIS_TIMESTAMP = 1606824023 # Dec 1, 2020, 12:00:23 UTC
SECONDS_PER_EPOCH = 384 # 32 slots × 12 seconds
def timestamp_to_epoch(timestamp: int) -> int:
"""Convert Unix timestamp to Ethereum epoch number."""
return (timestamp - GENESIS_TIMESTAMP) // SECONDS_PER_EPOCH
def epoch_to_timestamp(epoch: int) -> int:
"""Convert epoch number to Unix timestamp."""
return GENESIS_TIMESTAMP + (epoch * SECONDS_PER_EPOCH)
def get_tax_year_boundaries(year: int, tz_offset_hours: int = 0) -> dict:
"""
Get epoch boundaries for a tax year in a specific time zone.
Args:
year: The tax year (e.g., 2023, 2024)
tz_offset_hours: Time zone offset from UTC (e.g., 1 for CET, -5 for EST)
"""
tz = timezone(timedelta(hours=tz_offset_hours))
start_dt = datetime(year, 1, 1, 0, 0, 0, tzinfo=tz)
end_dt = datetime(year, 12, 31, 23, 59, 59, tzinfo=tz)
start_epoch = timestamp_to_epoch(int(start_dt.timestamp()))
end_epoch = timestamp_to_epoch(int(end_dt.timestamp()))
return {
'year': year,
'timezone': f"UTC{'+' if tz_offset_hours >= 0 else ''}{tz_offset_hours}",
'start_epoch': start_epoch,
'end_epoch': end_epoch,
'total_epochs': end_epoch - start_epoch + 1,
'start_date': start_dt.isoformat(),
'end_date': end_dt.isoformat()
}
# Example: Tax year 2024 in different time zones
for name, offset in [("UTC", 0), ("CET", 1), ("EST", -5)]:
b = get_tax_year_boundaries(2024, offset)
print(f"{name}: Epochs {b['start_epoch']:,} to {b['end_epoch']:,} ({b['total_epochs']:,} epochs)")
Output:UTC: Epochs 253,238 to 335,587 (82,350 epochs)
CET: Epochs 253,229 to 335,578 (82,350 epochs)
EST: Epochs 253,251 to 335,600 (82,350 epochs)
const GENESIS_TIMESTAMP = 1606824023;
const SECONDS_PER_EPOCH = 384;
function timestampToEpoch(timestamp) {
return Math.floor((timestamp - GENESIS_TIMESTAMP) / SECONDS_PER_EPOCH);
}
function epochToTimestamp(epoch) {
return GENESIS_TIMESTAMP + (epoch * SECONDS_PER_EPOCH);
}
function getTaxYearBoundaries(year, tzOffsetHours = 0) {
const startDate = new Date(Date.UTC(year, 0, 1, -tzOffsetHours, 0, 0));
const endDate = new Date(Date.UTC(year, 11, 31, 23 - tzOffsetHours, 59, 59));
const startEpoch = timestampToEpoch(Math.floor(startDate.getTime() / 1000));
const endEpoch = timestampToEpoch(Math.floor(endDate.getTime() / 1000));
return {
year,
timezone: `UTC${tzOffsetHours >= 0 ? '+' : ''}${tzOffsetHours}`,
startEpoch,
endEpoch,
totalEpochs: endEpoch - startEpoch + 1
};
}
// Example: Tax year 2024
[["UTC", 0], ["CET", 1], ["EST", -5]].forEach(([name, offset]) => {
const b = getTaxYearBoundaries(2024, offset);
console.log(`${name}: Epochs ${b.startEpoch.toLocaleString()} to ${b.endEpoch.toLocaleString()}`);
});
Calculating Tax Year Rewards with Price Data
Use the Custom Range Rewards approach to iterate through epochs and collect per-epoch rewards that can be matched with historical prices:
import requests
import time
from datetime import datetime, timezone, timedelta
from typing import Generator, List, Dict
GENESIS_TIMESTAMP = 1606824023
SECONDS_PER_EPOCH = 384
API_KEY = "<YOUR_API_KEY>"
API_BASE = "https://beaconcha.in/api/v2/ethereum/validators"
def timestamp_to_epoch(timestamp: int) -> int:
return (timestamp - GENESIS_TIMESTAMP) // SECONDS_PER_EPOCH
def epoch_to_timestamp(epoch: int) -> int:
return GENESIS_TIMESTAMP + (epoch * SECONDS_PER_EPOCH)
def get_tax_year_boundaries(year: int, tz_offset_hours: int = 0) -> dict:
tz = timezone(timedelta(hours=tz_offset_hours))
start_dt = datetime(year, 1, 1, 0, 0, 0, tzinfo=tz)
end_dt = datetime(year, 12, 31, 23, 59, 59, tzinfo=tz)
return {
'start_epoch': timestamp_to_epoch(int(start_dt.timestamp())),
'end_epoch': timestamp_to_epoch(int(end_dt.timestamp()))
}
def fetch_epoch_rewards(dashboard_id: int, epoch: int) -> Generator[dict, None, None]:
"""Fetch all validator rewards for a single epoch."""
cursor = None
while True:
payload = {
"chain": "mainnet",
"validator": {"dashboard_id": dashboard_id},
"epoch": epoch,
"page_size": 100
}
if cursor:
payload["cursor"] = cursor
response = requests.post(
f"{API_BASE}/rewards-list",
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json"
},
json=payload
)
response.raise_for_status()
data = response.json()
for reward in data.get("data", []):
yield reward
cursor = data.get("paging", {}).get("next_cursor")
if not cursor:
break
def calculate_tax_year_rewards_with_prices(
dashboard_id: int,
year: int,
tz_offset_hours: int = 0,
get_eth_price: callable = None # Function to get ETH price for a timestamp
) -> List[Dict]:
"""
Calculate rewards for each epoch in a tax year with fiat conversion.
Returns a list of daily aggregated rewards with fiat values.
"""
boundaries = get_tax_year_boundaries(year, tz_offset_hours)
start_epoch = boundaries['start_epoch']
end_epoch = boundaries['end_epoch']
# Aggregate rewards by day for practical tax reporting
daily_rewards = {}
for epoch in range(start_epoch, end_epoch + 1):
epoch_ts = epoch_to_timestamp(epoch)
date_key = datetime.fromtimestamp(
epoch_ts,
tz=timezone(timedelta(hours=tz_offset_hours))
).strftime('%Y-%m-%d')
if date_key not in daily_rewards:
daily_rewards[date_key] = {
'date': date_key,
'total_wei': 0,
'epochs': []
}
epoch_total = 0
for reward in fetch_epoch_rewards(dashboard_id, epoch):
epoch_total += int(reward.get("total", 0))
daily_rewards[date_key]['total_wei'] += epoch_total
daily_rewards[date_key]['epochs'].append(epoch)
# Convert to list and add fiat values
result = []
for date_key in sorted(daily_rewards.keys()):
day_data = daily_rewards[date_key]
eth_amount = day_data['total_wei'] / 1e18
# Get ETH price for this date (you need to implement this)
eth_price = get_eth_price(date_key) if get_eth_price else None
fiat_value = eth_amount * eth_price if eth_price else None
result.append({
'date': date_key,
'reward_eth': eth_amount,
'eth_price_usd': eth_price,
'fiat_value_usd': fiat_value,
'epoch_count': len(day_data['epochs'])
})
return result
# Example usage (you need to provide your own price data source)
def get_historical_eth_price(date_str: str) -> float:
"""
Implement this function to fetch historical ETH-USD price.
You might use CoinGecko, CryptoCompare, or your preferred data source.
"""
# Example: return cached/API price for the date
# return some_price_api.get_price('ETH', 'USD', date_str)
return 2500.00 # Placeholder
if __name__ == "__main__":
DASHBOARD_ID = 123
results = calculate_tax_year_rewards_with_prices(
dashboard_id=DASHBOARD_ID,
year=2024,
tz_offset_hours=1, # CET
get_eth_price=get_historical_eth_price
)
total_fiat = sum(r['fiat_value_usd'] or 0 for r in results)
total_eth = sum(r['reward_eth'] for r in results)
print(f"Tax Year 2024 Summary")
print(f"Total ETH: {total_eth:.6f}")
print(f"Total USD: ${total_fiat:,.2f}")
print(f"\nFirst 5 days:")
for r in results[:5]:
print(f" {r['date']}: {r['reward_eth']:.6f} ETH = ${r['fiat_value_usd']:,.2f}")
const GENESIS_TIMESTAMP = 1606824023;
const SECONDS_PER_EPOCH = 384;
const API_KEY = '<YOUR_API_KEY>';
const API_BASE = 'https://beaconcha.in/api/v2/ethereum/validators';
function timestampToEpoch(timestamp) {
return Math.floor((timestamp - GENESIS_TIMESTAMP) / SECONDS_PER_EPOCH);
}
function epochToTimestamp(epoch) {
return GENESIS_TIMESTAMP + (epoch * SECONDS_PER_EPOCH);
}
function getTaxYearBoundaries(year, tzOffsetHours = 0) {
const startDate = new Date(Date.UTC(year, 0, 1, -tzOffsetHours, 0, 0));
const endDate = new Date(Date.UTC(year, 11, 31, 23 - tzOffsetHours, 59, 59));
return {
startEpoch: timestampToEpoch(Math.floor(startDate.getTime() / 1000)),
endEpoch: timestampToEpoch(Math.floor(endDate.getTime() / 1000))
};
}
async function* fetchEpochRewards(dashboardId, epoch) {
let cursor = null;
while (true) {
const payload = {
chain: 'mainnet',
validator: { dashboard_id: dashboardId },
epoch,
page_size: 100
};
if (cursor) payload.cursor = cursor;
const response = await fetch(`${API_BASE}/rewards-list`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
const data = await response.json();
for (const reward of data.data || []) yield reward;
cursor = data.paging?.next_cursor;
if (!cursor) break;
}
}
async function calculateTaxYearRewardsWithPrices(dashboardId, year, tzOffsetHours, getEthPrice) {
const { startEpoch, endEpoch } = getTaxYearBoundaries(year, tzOffsetHours);
const dailyRewards = {};
for (let epoch = startEpoch; epoch <= endEpoch; epoch++) {
const epochTs = epochToTimestamp(epoch);
const date = new Date((epochTs + tzOffsetHours * 3600) * 1000);
const dateKey = date.toISOString().split('T')[0];
if (!dailyRewards[dateKey]) {
dailyRewards[dateKey] = { date: dateKey, totalWei: 0n, epochs: [] };
}
for await (const reward of fetchEpochRewards(dashboardId, epoch)) {
dailyRewards[dateKey].totalWei += BigInt(reward.total || '0');
}
dailyRewards[dateKey].epochs.push(epoch);
}
// Convert to array with fiat values
const results = [];
for (const dateKey of Object.keys(dailyRewards).sort()) {
const day = dailyRewards[dateKey];
const ethAmount = Number(day.totalWei) / 1e18;
const ethPrice = getEthPrice ? await getEthPrice(dateKey) : null;
results.push({
date: dateKey,
rewardEth: ethAmount,
ethPriceUsd: ethPrice,
fiatValueUsd: ethPrice ? ethAmount * ethPrice : null,
epochCount: day.epochs.length
});
}
return results;
}
API Usage: A full tax year contains ~82,000 epochs. Each epoch requires a separate API call. See Custom Range Rewards for performance estimates and optimization tips.
Obtaining Historical ETH Prices
You’ll need historical ETH-USD prices for each day. Common sources include:
| Source | Notes |
|---|
| CoinGecko API | Free tier available, daily historical prices |
| CryptoCompare | Hourly historical data available |
| Kraken | Exchange prices, high accuracy |
| Tax software | Many crypto tax tools include price data |
For tax compliance, use a consistent price source throughout your calculations and document which source you used.
Common Tax Year Time Zone Offsets
| Region | Time Zone | UTC Offset | Notes |
|---|
| UK | GMT/BST | 0 / +1 | BST during summer |
| Central Europe | CET/CEST | +1 / +2 | CEST during summer |
| US Eastern | EST/EDT | -5 / -4 | EDT during summer |
| US Pacific | PST/PDT | -8 / -7 | PDT during summer |
| Japan | JST | +9 | No daylight saving |
| Australia Eastern | AEST/AEDT | +10 / +11 | AEDT during summer |
For tax purposes, use your local time zone’s standard offset (non-daylight-saving) or consult with your tax authority about which time zone to use. Some jurisdictions may require UTC.
PRO Feature: With a Scale or Enterprise plan, you can query by withdrawal address to capture all validators, and benefit from significantly higher rate limits. Use dashboards with groups to organize validators and query them efficiently with a single dashboard_id.