diff --git a/schema.graphql b/schema.graphql index b6ac70d..54c9baf 100755 --- a/schema.graphql +++ b/schema.graphql @@ -108,6 +108,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 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 at the start of the current round. Used to compute the transcoder's share of delegator rewards earned by its own staked commission. Resets to zero on claim." + 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 +145,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 +219,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 2a49346..2769645 100755 --- a/src/mappings/bondingManager.ts +++ b/src/mappings/bondingManager.ts @@ -1,5 +1,6 @@ import { store } from "@graphprotocol/graph-ts"; import { + convertFromDecimal, convertToDecimal, createOrLoadDelegator, createOrLoadPool, @@ -10,9 +11,13 @@ import { EMPTY_ADDRESS, getBlockNum, makeEventId, + makePoolId, makeUnbondingLockId, MAXIMUM_VALUE_UINT256, ONE_BI, + percOf, + PRECISE_PERC_DIVISOR, + precisePercOf, ZERO_BI, } from "../../utils/helpers"; // Import event types from the registrar contract ABIs @@ -34,8 +39,10 @@ import { } from "../types/BondingManager/BondingManager"; import { BondEvent, + DelegatorSnapshot, EarningsClaimedEvent, ParameterUpdateEvent, + Pool, RebondEvent, RewardEvent, TranscoderActivatedEvent, @@ -134,12 +141,39 @@ export function bond(event: Bond): void { convertToDecimal(event.params.additionalAmount) ); + // Compute shares: bondedAmount * 10^27 / crf[lastClaimRound] + // shares is invariant across claims, only changes on bond/unbond + let poolForShares = Pool.load( + makePoolId(event.params.newDelegate.toHex(), round.id) + ); + let sharesRefCRF = PRECISE_PERC_DIVISOR; + if ( + poolForShares && + !poolForShares.cumulativeRewardFactor.equals(ZERO_BI) + ) { + sharesRefCRF = poolForShares.cumulativeRewardFactor; + } + delegator.shares = event.params.bondedAmount + .times(PRECISE_PERC_DIVISOR) + .div(sharesRefCRF); + round.save(); delegate.save(); delegator.save(); transcoder.save(); protocol.save(); + // Save delegator snapshot for historical stake/reward computation + let snapshotId = event.params.delegator.toHex() + "-" + round.id; + let snapshot = new DelegatorSnapshot(snapshotId); + snapshot.delegator = event.params.delegator.toHex(); + snapshot.delegate = event.params.newDelegate.toHex(); + snapshot.bondedAmount = delegator.bondedAmount; + snapshot.shares = delegator.shares; + snapshot.round = round.id; + snapshot.timestamp = event.block.timestamp.toI32(); + snapshot.save(); + createOrLoadTransactionFromEvent(event); let bondEvent = new BondEvent( @@ -259,6 +293,25 @@ export function unbond(event: Unbond): void { convertToDecimal(event.params.amount) ); + // Compute shares from new bonded amount + if (delegatorData.value0.isZero()) { + delegator.shares = ZERO_BI; + } else { + let poolForShares = Pool.load( + makePoolId(event.params.delegate.toHex(), round.id) + ); + let sharesRefCRF = PRECISE_PERC_DIVISOR; + if ( + poolForShares && + !poolForShares.cumulativeRewardFactor.equals(ZERO_BI) + ) { + sharesRefCRF = poolForShares.cumulativeRewardFactor; + } + delegator.shares = delegatorData.value0 + .times(PRECISE_PERC_DIVISOR) + .div(sharesRefCRF); + } + // Delegator no longer delegated to anyone if it does not have a bonded amount // so remove it from delegate if (delegatorData.value0.isZero()) { @@ -291,6 +344,17 @@ export function unbond(event: Unbond): void { protocol.save(); round.save(); + // Save delegator snapshot for historical stake/reward computation + let snapshotId = event.params.delegator.toHex() + "-" + round.id; + let snapshot = new DelegatorSnapshot(snapshotId); + snapshot.delegator = event.params.delegator.toHex(); + snapshot.delegate = delegator.delegate; + snapshot.bondedAmount = delegator.bondedAmount; + snapshot.shares = delegator.shares; + snapshot.round = round.id; + snapshot.timestamp = event.block.timestamp.toI32(); + snapshot.save(); + createOrLoadTransactionFromEvent(event); let unbondEvent = new UnbondEvent( @@ -351,6 +415,21 @@ export function rebond(event: Rebond): void { delegator.bondedAmount = convertToDecimal(delegatorData.value0); delegator.fees = convertToDecimal(delegatorData.value1); + // Compute shares: bondedAmount * 10^27 / crf[lastClaimRound] + let poolForShares = Pool.load( + makePoolId(event.params.delegate.toHex(), round.id) + ); + let sharesRefCRF = PRECISE_PERC_DIVISOR; + if ( + poolForShares && + !poolForShares.cumulativeRewardFactor.equals(ZERO_BI) + ) { + sharesRefCRF = poolForShares.cumulativeRewardFactor; + } + delegator.shares = delegatorData.value0 + .times(PRECISE_PERC_DIVISOR) + .div(sharesRefCRF); + // 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 @@ -371,6 +450,17 @@ export function rebond(event: Rebond): void { delegator.save(); protocol.save(); + // Save delegator snapshot for historical stake/reward computation + let snapshotId = event.params.delegator.toHex() + "-" + round.id; + let snapshot = new DelegatorSnapshot(snapshotId); + snapshot.delegator = event.params.delegator.toHex(); + snapshot.delegate = event.params.delegate.toHex(); + snapshot.bondedAmount = delegator.bondedAmount; + snapshot.shares = delegator.shares; + snapshot.round = round.id; + snapshot.timestamp = event.block.timestamp.toI32(); + snapshot.save(); + if (unbondingLock) { store.remove("UnbondingLock", uniqueUnbondingLockId); } @@ -494,6 +584,44 @@ export function reward(event: Reward): void { ); transcoder.lastRewardRound = round.id; + // 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 transcoderCommission = percOf(totalRewardTokens, pool.rewardCut); + let delegatorsRewards = totalRewardTokens.minus(transcoderCommission); + + // 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(transcoderCommission) + .plus(transcoderRewardStakeRewards); + transcoder.lifetimeRewardCommission = transcoder.lifetimeRewardCommission + .plus(transcoderCommission) + .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; @@ -671,6 +799,18 @@ 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; + transcoder.activeCumulativeRewards = ZERO_BI; + transcoder.save(); + } + createOrLoadTransactionFromEvent(event); let earningsClaimedEvent = new EarningsClaimedEvent( diff --git a/src/mappings/roundsManager.ts b/src/mappings/roundsManager.ts index c113a35..e6982a7 100644 --- a/src/mappings/roundsManager.ts +++ b/src/mappings/roundsManager.ts @@ -13,11 +13,13 @@ import { getBondingManagerAddress, getLptPriceEth, getTimestampForDaysPast, + integerFromString, makeEventId, 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 @@ -126,6 +128,13 @@ export function newRound(event: NewRound): void { // given transcoder and round then we know the transcoder failed to call reward() createOrLoadPool(round.id, currentTranscoder.toHex()); + if (transcoder) { + // Snapshot pendingRewardCommission as activeCumulativeRewards for this round, + // mirroring the contract's setCurrentRoundTotalActiveStake snapshot + transcoder.activeCumulativeRewards = transcoder.pendingRewardCommission; + transcoder.save(); + } + currentTranscoder = bondingManager.getNextTranscoderInPool(currentTranscoder); diff --git a/src/mappings/ticketBroker.ts b/src/mappings/ticketBroker.ts index 3aa9adf..710d15b 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, @@ -12,11 +13,19 @@ import { createOrLoadTranscoderDay, getBlockNum, getEthPriceUsd, + integerFromString, makeEventId, + makePoolId, + ONE_BI, + percOf, + PRECISE_PERC_DIVISOR, + precisePercOf, ZERO_BD, + ZERO_BI, } from "../../utils/helpers"; import { DepositFundedEvent, + Pool, ReserveClaimedEvent, ReserveFundedEvent, WinningTicketRedeemedEvent, @@ -118,8 +127,37 @@ 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 let pool = createOrLoadPool(round.id, event.params.recipient.toHex()); + + // Compute cumulative fee factor (matches on-chain PreciseMathUtils) + // Use previous round's CRF, matching contract's latestCumulativeFactorsPool(_round - 1) + 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; + } + + let delegatorsFees = percOf(event.params.faceValue, pool.feeShare); + let transcoderFeeCommission = event.params.faceValue.minus(delegatorsFees); + + // Accumulate orchestrator fee commission + transcoder.pendingFeeCommission = transcoder.pendingFeeCommission.plus(transcoderFeeCommission); + transcoder.lifetimeFeeCommission = transcoder.lifetimeFeeCommission.plus(transcoderFeeCommission); + + let totalStakeBI = convertFromDecimal(pool.totalStake); + if (totalStakeBI.gt(ZERO_BI)) { + pool.cumulativeFeeFactor = pool.cumulativeFeeFactor.plus( + precisePercOf(prevCRF, delegatorsFees, totalStakeBI) + ); + } + pool.fees = pool.fees.plus(faceValue); pool.save(); diff --git a/utils/helpers.ts b/utils/helpers.ts index 556744d..b070012 100644 --- a/utils/helpers.ts +++ b/utils/helpers.ts @@ -61,6 +61,22 @@ export function createOrLoadPool(roundId: string, transcoderAddress: string): Po pool.round = roundId; pool.delegate = transcoderAddress; pool.fees = ZERO_BD; + + // Propagate cumulative factors from the previous round's pool so every + // pool has valid factors even if the transcoder misses reward() or has + // no fees in a round. This mirrors the contract's latestCumulativeFactorsPool. + let prevRoundNum = integerFromString(roundId).minus(ONE_BI); + let prevPool = Pool.load( + makePoolId(transcoderAddress, prevRoundNum.toString()) + ); + if (prevPool) { + pool.cumulativeRewardFactor = prevPool.cumulativeRewardFactor; + pool.cumulativeFeeFactor = prevPool.cumulativeFeeFactor; + } else { + pool.cumulativeRewardFactor = ZERO_BI; + pool.cumulativeFeeFactor = ZERO_BI; + } + let transcoder = Transcoder.load(transcoderAddress); if (transcoder) { @@ -124,6 +140,36 @@ export function percPoints(_fracNum: BigInt, _fracDenom: BigInt): BigInt { return _fracNum.times(BigInt.fromI32(PERC_DIVISOR)).div(_fracDenom); } +// PreciseMathUtils equivalents (matches Solidity's 27-decimal fixed-point arithmetic) +export let PRECISE_PERC_DIVISOR = BigInt.fromString( + "1000000000000000000000000000" +); // 10^27 + +export function precisePercPoints( + _fracNum: BigInt, + _fracDenom: BigInt +): BigInt { + return _fracNum.times(PRECISE_PERC_DIVISOR).div(_fracDenom); +} + +export function precisePercOf( + _baseAmount: BigInt, + _fracNum: BigInt, + _fracDenom: BigInt +): BigInt { + return _baseAmount.times(_fracNum).div(_fracDenom); +} + +// 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)) { @@ -266,6 +312,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.pendingFeeCommission = ZERO_BI; + transcoder.lifetimeFeeCommission = ZERO_BI; + transcoder.activeCumulativeRewards = ZERO_BI; transcoder.save(); } @@ -287,6 +338,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(); }