Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
4f72235
feat: add cumulative reward/fee factors and delegator shares for effi…
adamsoffer Mar 8, 2026
766b384
feat: add cumulativeRewards on Transcoder for lifetime orchestrator c…
adamsoffer Mar 9, 2026
893d295
fix: reset cumulativeRewards on claim so it represents unclaimed comm…
adamsoffer Mar 9, 2026
f790cd8
feat: add lifetimeRewards on Transcoder for total commission earned
adamsoffer Mar 9, 2026
45818b8
feat: rename commission fields and add fee commission tracking
adamsoffer Mar 9, 2026
e3378c1
fix: match Solidity's PreciseMathUtils.percOf with single division
adamsoffer Mar 10, 2026
91eb05d
fix: include transcoderRewardStakeRewards in pendingRewardCommission
adamsoffer Mar 10, 2026
665d9b0
refactor: use BigInt.pow(27) instead of fromString for PRECISE_PERC_D…
rickstaa Mar 25, 2026
66387af
cleanup: remove unused precisePercPoints helper
rickstaa Mar 25, 2026
201b35e
style: match field initialization order with schema definition
rickstaa Mar 25, 2026
40caa7e
fix: fall back to lastRewardRound when previous pool is missing
rickstaa Mar 26, 2026
e7e593a
fix: include transcoderRewardStakeFees in fee commission
rickstaa Mar 26, 2026
9f39928
fix: use current pool's propagated CRF as prevCRF fallback on reactiv…
rickstaa Mar 26, 2026
e63c9bc
refactor: extract computeShares and saveDelegatorSnapshot helpers
rickstaa Mar 26, 2026
da8ff89
fix: match contract timing for activeCumulativeRewards and add lastFe…
rickstaa Mar 26, 2026
86ce1bb
refactor: move computeShares and saveDelegatorSnapshot to helpers
rickstaa Mar 26, 2026
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
38 changes: 38 additions & 0 deletions schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ type Transcoder @entity {
activationTimestamp: Int!
"Last round that the transcoder called reward"
lastRewardRound: Round
"Last round that the transcoder received fees"
lastFeeRound: Round
"% of block reward cut paid to transcoder by a delegator"
rewardCut: BigInt!
"The last timestamped update to reward cut, beginning at 12:00am UTC"
Expand Down Expand Up @@ -108,6 +110,16 @@ type Transcoder @entity {
serviceURI: String
"Days which the transcoder earned fees"
transcoderDays: [TranscoderDay!]!
"Unclaimed orchestrator reward commission in wei. Includes both rewardCut commission and compounding rewards earned on staked commission. Resets to zero on claim. Full pending stake = shares * crf / 10^27 + pendingRewardCommission"
pendingRewardCommission: BigInt!
"Lifetime total orchestrator reward commission earned in wei. Never resets."
lifetimeRewardCommission: BigInt!
"Snapshot of pendingRewardCommission taken at reward() call time (re-snapshotted from initial round-start value). Used to compute the transcoder's share of delegator rewards/fees earned by its own staked commission. Not reset on claim; the reward handler re-snapshots it."
activeCumulativeRewards: BigInt!
"Unclaimed orchestrator fee commission in wei. Resets to zero on claim."
pendingFeeCommission: BigInt!
"Lifetime total orchestrator fee commission earned in wei. Never resets."
lifetimeFeeCommission: BigInt!
}

enum TranscoderStatus @entity {
Expand Down Expand Up @@ -135,6 +147,10 @@ type Pool @entity {
rewardCut: BigInt!
"Transcoder's fee share during the earnings pool's round"
feeShare: BigInt!
"Cumulative reward factor for computing delegator rewards without looping (27-decimal fixed-point, matches on-chain PreciseMathUtils)"
cumulativeRewardFactor: BigInt!
"Cumulative fee factor for computing delegator fees without looping (27-decimal fixed-point, matches on-chain PreciseMathUtils)"
cumulativeFeeFactor: BigInt!
}

"""
Expand Down Expand Up @@ -205,10 +221,32 @@ type Delegator @entity {
withdrawnFees: BigDecimal!
"Amount of Livepeer Token the delegator has delegated"
delegatedAmount: BigDecimal!
"Proportional claim on the orchestrator's pool (bondedAmount * 10^27 / crf[lastClaimRound]). Invariant across claims, only changes on bond/unbond."
shares: BigInt!
"Unbonding locks associated with the delegator"
unbondingLocks: [UnbondingLock!] @derivedFrom(field: "delegator")
}

"""
Snapshot of delegator state at each state-changing event, enabling historical stake and reward computation via cumulative factors
"""
type DelegatorSnapshot @entity {
"Unique identifier: delegator address + round number"
id: ID!
"The delegator this snapshot belongs to"
delegator: Delegator!
"The delegate (orchestrator) at the time of this snapshot, null if fully unbonded"
delegate: Transcoder
"Bonded amount at the time of this snapshot"
bondedAmount: BigDecimal!
"Proportional claim on the orchestrator's pool. stake = shares * crf[round] / 10^27"
shares: BigInt!
"Round when this snapshot was taken"
round: Round!
"Timestamp when this snapshot was taken"
timestamp: Int!
}

"""
Abstraction for accounts/delegators bonded with the protocol
"""
Expand Down
105 changes: 105 additions & 0 deletions src/mappings/bondingManager.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { store } from "@graphprotocol/graph-ts";
import {
computeShares,
convertFromDecimal,
convertToDecimal,
createOrLoadDelegator,
createOrLoadProtocol,
Expand All @@ -13,6 +15,10 @@ import {
makeUnbondingLockId,
MAXIMUM_VALUE_UINT256,
ONE_BI,
percOf,
PRECISE_PERC_DIVISOR,
precisePercOf,
saveDelegatorSnapshot,
ZERO_BI,
} from "../../utils/helpers";
// Import event types from the registrar contract ABIs
Expand Down Expand Up @@ -135,12 +141,26 @@ export function bond(event: Bond): void {
convertToDecimal(event.params.additionalAmount)
);

delegator.shares = computeShares(
event.params.newDelegate.toHex(),
round.id,
event.params.bondedAmount
);

round.save();
delegate.save();
delegator.save();
transcoder.save();
protocol.save();

saveDelegatorSnapshot(
event.params.delegator.toHex(),
event.params.newDelegate.toHex(),
delegator,
round.id,
event.block.timestamp.toI32()
);

createOrLoadTransactionFromEvent(event);

let bondEvent = new BondEvent(
Expand Down Expand Up @@ -260,6 +280,12 @@ export function unbond(event: Unbond): void {
convertToDecimal(event.params.amount)
);

delegator.shares = computeShares(
event.params.delegate.toHex(),
round.id,
delegatorData.value0
);

// Delegator no longer delegated to anyone if it does not have a bonded amount
// so remove it from delegate
if (delegatorData.value0.isZero()) {
Expand Down Expand Up @@ -292,6 +318,14 @@ export function unbond(event: Unbond): void {
protocol.save();
round.save();

saveDelegatorSnapshot(
event.params.delegator.toHex(),
delegator.delegate ? delegator.delegate! : "",
delegator,
round.id,
event.block.timestamp.toI32()
);

createOrLoadTransactionFromEvent(event);

let unbondEvent = new UnbondEvent(
Expand Down Expand Up @@ -352,6 +386,12 @@ export function rebond(event: Rebond): void {
delegator.bondedAmount = convertToDecimal(delegatorData.value0);
delegator.fees = convertToDecimal(delegatorData.value1);

delegator.shares = computeShares(
event.params.delegate.toHex(),
round.id,
delegatorData.value0
);

// If the sender field for the lock is equal to the delegator's address then
// we know that this is an unbonding lock the delegator created by calling
// unbond() and if it is not then we know that this is an unbonding lock created
Expand All @@ -372,6 +412,14 @@ export function rebond(event: Rebond): void {
delegator.save();
protocol.save();

saveDelegatorSnapshot(
event.params.delegator.toHex(),
event.params.delegate.toHex(),
delegator,
round.id,
event.block.timestamp.toI32()
);

if (unbondingLock) {
store.remove("UnbondingLock", uniqueUnbondingLockId);
}
Expand Down Expand Up @@ -496,6 +544,49 @@ export function reward(event: Reward): void {
);
transcoder.lastRewardRound = round.id;

// Snapshot activeCumulativeRewards from pendingRewardCommission, mirroring
// the contract's updateTranscoderWithRewards (line 1490):
// t.activeCumulativeRewards = t.cumulativeRewards
transcoder.activeCumulativeRewards = transcoder.pendingRewardCommission;

// Compute cumulative reward factor (matches on-chain PreciseMathUtils)
// The pool's CRF was propagated from the previous round during pool creation,
// so it already contains the correct previous cumulative reward factor.
let prevCRF = pool!.cumulativeRewardFactor;
if (prevCRF.equals(ZERO_BI)) {
prevCRF = PRECISE_PERC_DIVISOR; // default: 10^27 = percPoints(1,1)
}

let totalRewardTokens = event.params.amount; // raw BigInt in wei
let transcoderCommissionRewards = percOf(totalRewardTokens, pool!.rewardCut);
let delegatorsRewards = totalRewardTokens.minus(transcoderCommissionRewards);

// Compute rewards earned by the transcoder's own staked commission
let totalStakeBI = convertFromDecimal(pool!.totalStake);
let transcoderRewardStakeRewards = ZERO_BI;
if (totalStakeBI.gt(ZERO_BI)) {
transcoderRewardStakeRewards = precisePercOf(
delegatorsRewards,
transcoder.activeCumulativeRewards,
totalStakeBI
);
}

// Accumulate orchestrator reward commission (rewardCut + rewards on staked commission)
transcoder.pendingRewardCommission = transcoder.pendingRewardCommission
.plus(transcoderCommissionRewards)
.plus(transcoderRewardStakeRewards);
transcoder.lifetimeRewardCommission = transcoder.lifetimeRewardCommission
.plus(transcoderCommissionRewards)
.plus(transcoderRewardStakeRewards);
if (totalStakeBI.gt(ZERO_BI)) {
pool!.cumulativeRewardFactor = prevCRF.plus(
precisePercOf(prevCRF, delegatorsRewards, totalStakeBI)
);
} else {
pool!.cumulativeRewardFactor = prevCRF;
}

pool!.rewardTokens = convertToDecimal(event.params.amount);
pool!.feeShare = transcoder.feeShare;
pool!.rewardCut = transcoder.rewardCut;
Expand Down Expand Up @@ -673,6 +764,20 @@ export function earningsClaimed(event: EarningsClaimed): void {
delegator.fees = delegator.fees.plus(convertToDecimal(event.params.fees));
delegator.save();

// Reset orchestrator's unclaimed commission when they claim
if (event.params.delegator.toHex() == event.params.delegate.toHex()) {
let transcoder = createOrLoadTranscoder(
event.params.delegator.toHex(),
event.block.timestamp.toI32()
);
transcoder.pendingRewardCommission = ZERO_BI;
transcoder.pendingFeeCommission = ZERO_BI;
// activeCumulativeRewards is NOT reset here — the contract preserves it
// until the next reward() call. The claimed commission stays in totalStake
// (as regular bondedAmount) and should still earn its share of fees.
transcoder.save();
}

createOrLoadTransactionFromEvent(event);

let earningsClaimedEvent = new EarningsClaimedEvent(
Expand Down
43 changes: 43 additions & 0 deletions src/mappings/roundsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ import {
getBondingManagerAddress,
getLptPriceEth,
getTimestampForDaysPast,
integerFromString,
makeEventId,
makePoolId,
ONE_BD,
ONE_BI,
PERC_DIVISOR,
ZERO_BD,
ZERO_BI,
} from "../../utils/helpers";
import { BondingManager } from "../types/BondingManager/BondingManager";
// Import event types from the registrar contract ABIs
Expand Down Expand Up @@ -132,10 +134,51 @@ export function newRound(event: NewRound): void {
pool.round = round.id;
pool.delegate = currentTranscoder.toHex();
pool.fees = ZERO_BD;

// Ensure every pool has valid cumulative factors even when reward() is
// missed or no fees are earned. Mirrors the contract's
// latestCumulativeFactorsPool(): try the previous round, fall back to
// lastRewardRound / lastFeeRound independently if the transcoder was inactive.
let prevRoundNum = integerFromString(round.id).minus(ONE_BI);
let prevPool = Pool.load(
makePoolId(currentTranscoder.toHex(), prevRoundNum.toString())
);
if (prevPool) {
pool.cumulativeRewardFactor = prevPool.cumulativeRewardFactor;
pool.cumulativeFeeFactor = prevPool.cumulativeFeeFactor;
} else {
// CRF fallback to lastRewardRound
pool.cumulativeRewardFactor = ZERO_BI;
if (transcoder && transcoder.lastRewardRound) {
let rewardPool = Pool.load(
makePoolId(currentTranscoder.toHex(), transcoder.lastRewardRound)
);
if (rewardPool) {
pool.cumulativeRewardFactor = rewardPool.cumulativeRewardFactor;
}
}
// CFF fallback to lastFeeRound
pool.cumulativeFeeFactor = ZERO_BI;
if (transcoder && transcoder.lastFeeRound) {
let feePool = Pool.load(
makePoolId(currentTranscoder.toHex(), transcoder.lastFeeRound)
);
if (feePool) {
pool.cumulativeFeeFactor = feePool.cumulativeFeeFactor;
}
}
}

if (transcoder) {
pool.totalStake = transcoder.totalStake;
pool.rewardCut = transcoder.rewardCut;
pool.feeShare = transcoder.feeShare;

// Initial snapshot of activeCumulativeRewards for fees that arrive before
// reward(). The reward handler re-snapshots this to match the contract's
// exact timing (updateTranscoderWithRewards line 1490).
transcoder.activeCumulativeRewards = transcoder.pendingRewardCommission;
transcoder.save();
}
pool.save();

Expand Down
60 changes: 59 additions & 1 deletion src/mappings/ticketBroker.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Address, BigInt, dataSource, log } from "@graphprotocol/graph-ts";
import {
convertFromDecimal,
convertToDecimal,
createOrLoadBroadcaster,
createOrLoadBroadcasterDay,
Expand All @@ -11,9 +12,15 @@ import {
createOrLoadTranscoderDay,
getBlockNum,
getEthPriceUsd,
integerFromString,
makeEventId,
makePoolId,
ONE_BI,
percOf,
PRECISE_PERC_DIVISOR,
precisePercOf,
ZERO_BD,
ZERO_BI,
} from "../../utils/helpers";
import {
DepositFundedEvent,
Expand Down Expand Up @@ -113,8 +120,59 @@ export function winningTicketRedeemed(event: WinningTicketRedeemed): void {
protocol.winningTicketCount = protocol.winningTicketCount + 1;
protocol.save();

// update the transcoder pool fees
// update the transcoder pool fees and cumulative fee factor
if (pool) {
// Use previous round's CRF for fee factor calculation, matching the
// contract's latestCumulativeFactorsPool(currentRound - 1). Fall back
// to the current pool's propagated CRF on reactivation (no prev pool).
let prevRoundNum = integerFromString(round.id).minus(ONE_BI);
let prevPoolForFees = Pool.load(
makePoolId(event.params.recipient.toHex(), prevRoundNum.toString())
);
let prevCRF = PRECISE_PERC_DIVISOR; // default: 10^27
if (
prevPoolForFees &&
!prevPoolForFees.cumulativeRewardFactor.equals(ZERO_BI)
) {
prevCRF = prevPoolForFees.cumulativeRewardFactor;
} else if (!pool.cumulativeRewardFactor.equals(ZERO_BI)) {
// Current pool's CRF was propagated from lastRewardRound in newRound,
// so it holds the correct previous factor when prev pool doesn't exist.
prevCRF = pool.cumulativeRewardFactor;
}

let delegatorsFees = percOf(event.params.faceValue, pool.feeShare);
let transcoderCommissionFees = event.params.faceValue.minus(delegatorsFees);

// Compute fees earned by the transcoder's own staked commission.
// If reward() hasn't been called yet this round, use pendingRewardCommission
// directly (mirrors contract's updateTranscoderWithFees line 339).
let activeCumulativeRewards = transcoder.lastRewardRound == round.id
? transcoder.activeCumulativeRewards
: transcoder.pendingRewardCommission;

let totalStakeBI = convertFromDecimal(pool.totalStake);
let transcoderRewardStakeFees = ZERO_BI;
if (totalStakeBI.gt(ZERO_BI)) {
transcoderRewardStakeFees = precisePercOf(
delegatorsFees,
activeCumulativeRewards,
totalStakeBI
);
}

// Accumulate orchestrator fee commission (feeShare cut + fees on staked commission)
let totalFeeCommission = transcoderCommissionFees.plus(transcoderRewardStakeFees);
transcoder.pendingFeeCommission = transcoder.pendingFeeCommission.plus(totalFeeCommission);
transcoder.lifetimeFeeCommission = transcoder.lifetimeFeeCommission.plus(totalFeeCommission);

if (totalStakeBI.gt(ZERO_BI)) {
pool.cumulativeFeeFactor = pool.cumulativeFeeFactor.plus(
precisePercOf(prevCRF, delegatorsFees, totalStakeBI)
);
}

transcoder.lastFeeRound = round.id;
pool.fees = pool.fees.plus(faceValue);
pool.save();
}
Expand Down
Loading
Loading