Skip to content
36 changes: 36 additions & 0 deletions schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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!
}

"""
Expand Down Expand Up @@ -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!
}
Comment on lines +231 to +246

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)(schema\.graphql|bondingManager\.ts)$' || true

echo "== schema relevant =="
sed -n '210,260p' schema.graphql 2>/dev/null || true

echo "== bonding manager outline =="
ast-grep outline bondingManager.ts 2>/dev/null | sed -n '1,220p' || true

echo "== bonding manager delegator snapshot usages =="
rg -n "DelegatorSnapshot|delegator.*round|new DelegatorSnapshot|snapshot" bondingManager.ts schema.graphql 2>/dev/null || true

echo "== context bond/unbond snippets =="
rg -n "bond|rebond|unbond|snapshot|DelegatorSnapshot" bondingManager.ts -C 3

Repository: livepeer/subgraph

Length of output: 2972


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate bonding manager =="
fd -a 'bondingManager\.ts$' . || true
find . -path '*/node_modules' -prune -o -name 'bondingManager.ts' -print

echo "== grep snapshots in repo =="
rg -n "new DelegatorSnapshot|DelegatorSnapshot\\(|delegator.*round|bondingManager|bond\\(|rebond\\(|unbond\\(-" . -g '!node_modules' -g '!dist' -g '!build' || true

echo "== list target file contents sizes and first 120 lines if exists =="
if [ -f src/mappings/bondingManager.ts ]; then
  wc -l src/mappings/bondingManager.ts
  sed -n '1,180p' src/mappings/bondingManager.ts
  echo "== delegation-related occurrences in file =="
  rg -n "DelegatorSnapshot|bond|rebond|unbond" src/mappings/bondingManager.ts -C 4
fi

Repository: livepeer/subgraph

Length of output: 32746


Make DelegatorSnapshot.id unique per event.

id is fixed to delegator + round, while bond, unbond, and rebond all write new DelegatorSnapshot(...snapshotId).save() for the same key. Multiple state-changing events for the same delegator in one round will overwrite earlier snapshots, contradicting the snapshot “at each state-changing event” semantics. Include a per-event component, e.g. tx hash + log index, in the snapshot ID.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@schema.graphql` around lines 231 - 246, Update DelegatorSnapshot ID
construction in the bond, unbond, and rebond event handlers to include a unique
per-event component such as transaction hash and log index, rather than only
delegator and round. Ensure each state-changing event creates a distinct
snapshot while preserving the existing DelegatorSnapshot fields and save flow.


"""
Abstraction for accounts/delegators bonded with the protocol
"""
Expand Down
140 changes: 140 additions & 0 deletions src/mappings/bondingManager.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { store } from "@graphprotocol/graph-ts";
import {
convertFromDecimal,
convertToDecimal,
createOrLoadDelegator,
createOrLoadPool,
Expand All @@ -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
Expand All @@ -34,8 +39,10 @@ import {
} from "../types/BondingManager/BondingManager";
import {
BondEvent,
DelegatorSnapshot,
EarningsClaimedEvent,
ParameterUpdateEvent,
Pool,
RebondEvent,
RewardEvent,
TranscoderActivatedEvent,
Expand Down Expand Up @@ -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();
Comment on lines +166 to +175

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Snapshot id collision on multiple same-round events for a delegator.

snapshotId = delegator + "-" + round.id will collide with unbond()/rebond() snapshots for the same delegator in the same round, silently overwriting earlier state. See consolidated comment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mappings/bondingManager.ts` around lines 166 - 175, The DelegatorSnapshot
identifier in the snapshot creation block collides with snapshots from unbond()
and rebond() within the same round. Update snapshotId to include a unique
event-specific component while retaining the delegator and round context,
ensuring multiple same-round events produce distinct records without overwriting
earlier state.


createOrLoadTransactionFromEvent(event);

let bondEvent = new BondEvent(
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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);
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
9 changes: 9 additions & 0 deletions src/mappings/roundsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand Down
40 changes: 39 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 @@ -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,
Expand Down Expand Up @@ -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;
}
Comment on lines +135 to +145

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Same exact round-1 lookback fragility as createOrLoadPool.

prevPoolForFees is looked up at exactly round.id - 1; if no Pool exists for that round for this transcoder (e.g. it was out of the active set), this silently falls back to the PRECISE_PERC_DIVISOR default rather than the transcoder's actual last cumulative reward factor. See consolidated comment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mappings/ticketBroker.ts` around lines 135 - 145, Update the previous
cumulative reward factor lookup in the ticket-broker flow around prevPoolForFees
so it searches backward through earlier rounds until it finds the transcoder’s
most recent Pool with a nonzero cumulativeRewardFactor, rather than only
checking round.id - 1. Preserve PRECISE_PERC_DIVISOR as the fallback when no
prior qualifying Pool exists, and use the existing pool-loading helpers or
lookup pattern from createOrLoadPool.


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();

Expand Down
Loading