Staking Rewards Tax Accounting System Development
Staking rewards are one of the most controversial issues in crypto taxation. In the US, the IRS (Rev. Rul. 2023-14) officially confirmed: staking rewards are ordinary income at receipt at fair market value. In Germany, there is a different interpretation. The system must account for jurisdictional differences.
Types of Staking and Tax Treatment
const STAKING_TAX_TREATMENT: Record<string, StakingTaxRule> = {
NATIVE_STAKING: {
// ETH 2.0, SOL, ADA — direct network staking
US: { type: "ordinary_income", timing: "on_receipt" },
DE: { type: "other_income", timing: "on_receipt", annualExempt: 256 },
UK: { type: "miscellaneous_income", timing: "on_receipt" },
},
LIQUID_STAKING: {
// stETH, rETH, mSOL — get liquid token
// Controversial: is receiving stETH taxable?
US: { type: "unknown", note: "IRS has not commented explicitly" },
DE: { type: "non_taxable", note: "swap considered continuation" },
},
REBASING: {
// stETH rebasing — balance increases, no separate reward transactions
US: { type: "ordinary_income", timing: "on_receipt", challenge: "hard_to_track" },
},
VALIDATOR_REWARDS: {
// ETH validator operators — in some interpretations business income
US: { type: "business_income_or_ordinary_income" },
},
};
Tracking Staking Rewards
class StakingRewardTracker {
// Ethereum staking via Lido
async trackLidoRewards(walletAddress: string, since: Date): Promise<StakingReward[]> {
// stETH uses rebasing — balance updates daily
// Need snapshots on each rebase
const rebaseEvents = await this.getLidoRebaseEvents(since);
const rewards: StakingReward[] = [];
let previousBalance = await this.getStETHBalance(walletAddress, since);
for (const rebase of rebaseEvents) {
const newBalance = await this.getStETHBalance(walletAddress, rebase.timestamp);
const rewardAmount = newBalance - previousBalance;
if (rewardAmount > 0) {
const ethPrice = await this.priceService.getHistoricalPrice("stETH", rebase.timestamp);
rewards.push({
timestamp: rebase.timestamp,
protocol: "Lido",
asset: "stETH",
amount: rewardAmount,
usdValue: rewardAmount * ethPrice,
rewardType: "REBASING",
costBasis: rewardAmount * ethPrice, // cost basis = FMV at receipt
});
}
previousBalance = newBalance;
}
return rewards;
}
// Ethereum 2.0 validator rewards
async trackETH2ValidatorRewards(validatorIndex: number, since: Date): Promise<StakingReward[]> {
const beaconChainData = await fetch(
`https://beaconcha.in/api/v1/validator/${validatorIndex}/incomedetail?limit=100`
).then(r => r.json());
return beaconChainData.data
.filter((r: any) => new Date(r.epoch_timestamp) >= since)
.map(async (r: any) => {
const timestamp = new Date(r.epoch_timestamp);
const ethPrice = await this.priceService.getHistoricalPrice("ETH", timestamp);
const rewardETH = r.income.attestation_source_reward / 1e9; // gwei → ETH
return {
timestamp,
protocol: "Ethereum 2.0 Validator",
asset: "ETH",
amount: rewardETH,
usdValue: rewardETH * ethPrice,
validatorIndex,
epoch: r.epoch,
};
});
}
// Solana staking rewards
async trackSolanaRewards(walletAddress: string, since: Date): Promise<StakingReward[]> {
// Use Solana RPC getInflationReward
const connection = new Connection(SOLANA_RPC);
const rewardHistory = await connection.getInflationReward(
[walletAddress],
{ epoch: await this.getEpochSince(since) }
);
return rewardHistory.map(r => ({
timestamp: epochToTimestamp(r.epoch),
protocol: "Solana Staking",
asset: "SOL",
amount: r.amount / 1e9, // lamports → SOL
usdValue: (r.amount / 1e9) * solPriceAtEpoch,
}));
}
}
Summary Staking Report
async function generateStakingTaxReport(
userId: string,
taxYear: number,
jurisdiction: string
): Promise<StakingTaxReport> {
const rewards = await db.getStakingRewards(userId, taxYear);
// Group by protocol and month
const byProtocol = groupBy(rewards, r => r.protocol);
const monthly = groupByMonth(rewards);
const totalIncome = rewards.reduce((sum, r) => sum + r.usdValue, 0);
// For Germany: check Freigrenze €256
const taxableAmount = jurisdiction === "DE"
? Math.max(0, totalIncome - 256)
: totalIncome;
return {
taxYear,
jurisdiction,
totalRewardsUSD: totalIncome,
taxableAmountUSD: taxableAmount,
byProtocol: Object.entries(byProtocol).map(([protocol, rewards]) => ({
protocol,
totalRewards: rewards.reduce((sum, r) => sum + r.amount, 0),
totalUSD: rewards.reduce((sum, r) => sum + r.usdValue, 0),
})),
monthlyBreakdown: monthly,
rewards: rewards.map(r => ({
...r,
costBasisForFutureDisposal: r.usdValue, // cost basis = income value
})),
};
}
Special Feature: Cost Basis on Future Sale
Important: staking reward received and included in ordinary income creates cost basis for future sale. The system must create a tax lot:
// When accounting for staking reward — create a lot
await db.createTaxLot({
userId,
asset: reward.asset,
amount: reward.amount,
costPerUnitUSD: reward.usdValue / reward.amount,
totalCostUSD: reward.usdValue,
acquiredAt: reward.timestamp,
source: "staking_reward",
acquisitionType: "INCOME", // distinguish from regular purchase
});
Staking rewards accounting system supporting Lido, ETH2 validator, Solana, Cosmos — 3-4 weeks development.







