diff --git a/schema.graphql b/schema.graphql index 9febcfc..d653616 100755 --- a/schema.graphql +++ b/schema.graphql @@ -108,6 +108,14 @@ type Transcoder @entity { serviceURI: String "Days which the transcoder earned fees" transcoderDays: [TranscoderDay!]! + "Unclaimed orchestrator reward commission (rewardCut portion) in wei. Resets to zero on claim. Full pending stake = shares * crf / 10^27 + pendingRewardCommission" + pendingRewardCommission: BigInt! + "Lifetime total orchestrator reward commission earned (rewardCut portion) in wei. Never resets." + lifetimeRewardCommission: 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 +143,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 +217,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..bcfd3ab 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, createOrLoadProtocol, @@ -13,6 +14,9 @@ import { 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,6 +38,7 @@ import { } from "../types/BondingManager/BondingManager"; import { BondEvent, + DelegatorSnapshot, EarningsClaimedEvent, ParameterUpdateEvent, Pool, @@ -135,12 +140,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( @@ -260,6 +292,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()) { @@ -292,6 +343,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( @@ -352,6 +414,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 @@ -372,6 +449,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); } @@ -496,6 +584,31 @@ 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); + + // Accumulate orchestrator reward commission + transcoder.pendingRewardCommission = transcoder.pendingRewardCommission.plus(transcoderCommission); + transcoder.lifetimeRewardCommission = transcoder.lifetimeRewardCommission.plus(transcoderCommission); + + let totalStakeBI = convertFromDecimal(pool!.totalStake); + 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 +786,17 @@ 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.save(); + } + createOrLoadTransactionFromEvent(event); let earningsClaimedEvent = new EarningsClaimedEvent( diff --git a/src/mappings/roundsManager.ts b/src/mappings/roundsManager.ts index 6ed9919..98e91a6 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,6 +134,24 @@ export function newRound(event: NewRound): void { pool.round = round.id; pool.delegate = currentTranscoder.toHex(); 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(round.id).minus(ONE_BI); + let prevPoolId = makePoolId( + currentTranscoder.toHex(), + prevRoundNum.toString() + ); + let prevPool = Pool.load(prevPoolId); + if (prevPool) { + pool.cumulativeRewardFactor = prevPool.cumulativeRewardFactor; + pool.cumulativeFeeFactor = prevPool.cumulativeFeeFactor; + } else { + pool.cumulativeRewardFactor = ZERO_BI; + pool.cumulativeFeeFactor = ZERO_BI; + } + if (transcoder) { pool.totalStake = transcoder.totalStake; pool.rewardCut = transcoder.rewardCut; diff --git a/src/mappings/ticketBroker.ts b/src/mappings/ticketBroker.ts index b929106..6c2ddd4 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,36 @@ 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) { + // 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 d068d43..78e7af0 100644 --- a/utils/helpers.ts +++ b/utils/helpers.ts @@ -96,6 +96,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)) { @@ -244,6 +274,10 @@ 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.save(); } @@ -265,6 +299,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(); }