diff --git a/schema.graphql b/schema.graphql index 9febcfc..b75af42 100755 --- a/schema.graphql +++ b/schema.graphql @@ -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" @@ -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 { @@ -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! } """ @@ -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 """ diff --git a/src/mappings/bondingManager.ts b/src/mappings/bondingManager.ts index fa94260..18de294 100755 --- a/src/mappings/bondingManager.ts +++ b/src/mappings/bondingManager.ts @@ -1,5 +1,7 @@ import { store } from "@graphprotocol/graph-ts"; import { + computeShares, + convertFromDecimal, convertToDecimal, createOrLoadDelegator, createOrLoadProtocol, @@ -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 @@ -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( @@ -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()) { @@ -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( @@ -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 @@ -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); } @@ -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; @@ -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( diff --git a/src/mappings/roundsManager.ts b/src/mappings/roundsManager.ts index 6ed9919..cf40baf 100644 --- a/src/mappings/roundsManager.ts +++ b/src/mappings/roundsManager.ts @@ -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 @@ -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(); diff --git a/src/mappings/ticketBroker.ts b/src/mappings/ticketBroker.ts index b929106..7177975 100644 --- a/src/mappings/ticketBroker.ts +++ b/src/mappings/ticketBroker.ts @@ -1,5 +1,6 @@ import { Address, BigInt, dataSource, log } from "@graphprotocol/graph-ts"; import { + convertFromDecimal, convertToDecimal, createOrLoadBroadcaster, createOrLoadBroadcasterDay, @@ -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, @@ -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(); } diff --git a/utils/helpers.ts b/utils/helpers.ts index d068d43..aca990f 100644 --- a/utils/helpers.ts +++ b/utils/helpers.ts @@ -12,7 +12,9 @@ import { BroadcasterDay, Day, Delegator, + DelegatorSnapshot, LivepeerAccount, + Pool, Protocol, Round, Transaction, @@ -31,6 +33,7 @@ export let EMPTY_ADDRESS = Address.fromString( "0000000000000000000000000000000000000000" ); export let PERC_DIVISOR = 1000000; +export let PRECISE_PERC_DIVISOR = BigInt.fromI32(10).pow(27); export let ZERO_BI = BigInt.fromI32(0); export let ONE_BI = BigInt.fromI32(1); @@ -96,6 +99,37 @@ export function percPoints(_fracNum: BigInt, _fracDenom: BigInt): BigInt { return _fracNum.times(BigInt.fromI32(PERC_DIVISOR)).div(_fracDenom); } +export function precisePercOf( + _baseAmount: BigInt, + _fracNum: BigInt, + _fracDenom: BigInt +): BigInt { + return _baseAmount.times(_fracNum).div(_fracDenom); +} + +// Compute delegator shares: bondedAmount * 10^27 / CRF[currentRound] +export function computeShares(delegate: string, roundId: string, bondedAmount: BigInt): BigInt { + if (bondedAmount.isZero()) { + return ZERO_BI; + } + let pool = Pool.load(makePoolId(delegate, roundId)); + let crf = PRECISE_PERC_DIVISOR; + if (pool && !pool.cumulativeRewardFactor.equals(ZERO_BI)) { + crf = pool.cumulativeRewardFactor; + } + return bondedAmount.times(PRECISE_PERC_DIVISOR).div(crf); +} + +// Convert BigDecimal (in token units) back to raw BigInt (in wei) +export function convertFromDecimal(amount: BigDecimal): BigInt { + let str = amount.times(exponentToBigDecimal(BI_18)).toString(); + let dotIndex = str.indexOf("."); + if (dotIndex >= 0) { + str = str.substring(0, dotIndex); + } + return BigInt.fromString(str); +} + export function exponentToBigDecimal(decimals: BigInt): BigDecimal { let bd = BigDecimal.fromString("1"); for (let i = ZERO_BI; i.lt(decimals); i = i.plus(ONE_BI)) { @@ -244,6 +278,11 @@ export function createOrLoadTranscoder(id: string, timestamp: i32): Transcoder { transcoder.sixtyDayVolumeETH = ZERO_BD; transcoder.ninetyDayVolumeETH = ZERO_BD; transcoder.transcoderDays = []; + transcoder.pendingRewardCommission = ZERO_BI; + transcoder.lifetimeRewardCommission = ZERO_BI; + transcoder.activeCumulativeRewards = ZERO_BI; + transcoder.pendingFeeCommission = ZERO_BI; + transcoder.lifetimeFeeCommission = ZERO_BI; transcoder.save(); } @@ -265,6 +304,7 @@ export function createOrLoadDelegator(id: string, timestamp: i32): Delegator { delegator.fees = ZERO_BD; delegator.withdrawnFees = ZERO_BD; delegator.delegatedAmount = ZERO_BD; + delegator.shares = ZERO_BI; delegator.save(); } @@ -275,6 +315,24 @@ export function createOrLoadDelegator(id: string, timestamp: i32): Delegator { return delegator; } +// Save a point-in-time record of delegator state for historical queries +export function saveDelegatorSnapshot( + delegatorAddress: string, + delegate: string, + delegator: Delegator, + roundId: string, + timestamp: i32 +): void { + let snapshot = new DelegatorSnapshot(delegatorAddress + "-" + roundId); + snapshot.delegator = delegatorAddress; + snapshot.delegate = delegate; + snapshot.bondedAmount = delegator.bondedAmount; + snapshot.shares = delegator.shares; + snapshot.round = roundId; + snapshot.timestamp = timestamp; + snapshot.save(); +} + export function createOrUpdateLivepeerAccount(id: string, timestamp: i32): LivepeerAccount { let account = LivepeerAccount.load(id); if (account == null) {