From 73c0d5bc12c2cc8e4c4e2d261688a5e2dd6d33a7 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Mon, 3 Aug 2026 16:37:05 +0200 Subject: [PATCH] fix: show delegated reward calls in account history Account history is keyed on the transaction sender: transactions(where: { from: $account }) Since LIP-118 (livepeer/protocol#648) an orchestrator can nominate a reward caller to submit reward() on its behalf. That transaction is sent by the caller, so it drops out of the query and every event inside it - including the RewardEvent - disappears from the orchestrator's history. The event itself was always attributed correctly: RewardEvent.delegate is the orchestrator regardless of who signed. The blind spot is purely in how the Explorer asks for it. It cannot be fixed in place either, because Transaction.events is typed as the Event interface, whose filter exposes only id/timestamp/transaction/round - delegate lives on the concrete RewardEvent type, so events_: { delegate: ... } does not exist. So query the event entity directly, keyed on the orchestrator. This is the same shape as winningTicketRedeemedEvents, already a separate role-keyed query in this file for the same reason: a ticket's recipient is an event param, so it survives being redeemed by a separate wallet. Excluding RewardEvent from the transaction-path list is what keeps self-called rewards, which appear in both queries, from rendering twice - there is no id-based dedupe in this component. Also require every list to be exhausted before paging stops. Keying that on transactions alone cuts off exactly the accounts this fixes: an orchestrator delegating every reward call has few transactions but one reward event per round. This equally affects gateways with more tickets than transactions, which 90ab4a1 fixed for totalLoaded but not reachedEnd. Delegated calls render with the existing copy. Distinguishing them would mean comparing transaction.from against the account, which is approximate - a multisig or relayer shows as the sender. The authoritative answer is Transcoder.rewardCaller, pending livepeer/subgraph#253. --- apollo/subgraph.ts | 24 ++++++++++++++--- components/HistoryView/index.tsx | 44 +++++++++++++++++++++++++------- queries/transactions.graphql | 22 ++++++++++++++++ 3 files changed, 78 insertions(+), 12 deletions(-) diff --git a/apollo/subgraph.ts b/apollo/subgraph.ts index e7511b3e..adfa80fc 100644 --- a/apollo/subgraph.ts +++ b/apollo/subgraph.ts @@ -5764,8 +5764,8 @@ export type Transaction = { id: Scalars['ID']; /** Timestamp for transaction */ timestamp: Scalars['Int']; - /** The receiving party of the transaction */ - to: Scalars['String']; + /** The receiving party of the transaction - null for contract-creation transactions */ + to?: Maybe; }; @@ -9707,7 +9707,7 @@ export type TransactionsQueryVariables = Exact<{ }>; -export type TransactionsQuery = { __typename: 'Query', transactions: Array<{ __typename: 'Transaction', events?: Array<{ __typename: 'BondEvent', additionalAmount: string, delegator: { __typename: 'Delegator', id: string }, newDelegate: { __typename: 'Transcoder', id: string }, oldDelegate?: { __typename: 'Transcoder', id: string } | null, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'BurnEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'DepositFundedEvent', amount: string, sender: { __typename: 'Broadcaster', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'EarningsClaimedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'MigrateDelegatorFinalizedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'MintEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'NewRoundEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'ParameterUpdateEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'PauseEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'PollCreatedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'RebondEvent', amount: string, delegate: { __typename: 'Transcoder', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'ReserveClaimedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'ReserveFundedEvent', amount: string, reserveHolder: { __typename: 'Broadcaster', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'RewardEvent', rewardTokens: string, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'ServiceURIUpdateEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'SetCurrentRewardTokensEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'StakeClaimedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'TranscoderActivatedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'TranscoderDeactivatedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'TranscoderEvictedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'TranscoderResignedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'TranscoderSlashedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'TranscoderUpdateEvent', rewardCut: string, feeShare: string, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'TransferBondEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'TreasuryVoteEvent', id: string, reason?: string | null, support: TreasuryVoteSupport, timestamp: number, weight: string, proposal: { __typename: 'TreasuryProposal', id: string, targets: Array, description: string }, treasuryVoter: { __typename: 'LivepeerAccount', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'UnbondEvent', amount: string, delegate: { __typename: 'Transcoder', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'UnpauseEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'VoteEvent', voter: string, choiceID: string, id: string, timestamp: number, poll: { __typename: 'Poll', id: string, proposal: string, endBlock: string, quorum: string, quota: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number }, round: { __typename: 'Round', id: string } } | { __typename: 'WinningTicketRedeemedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'WithdrawFeesEvent', amount: string, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'WithdrawStakeEvent', amount: string, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'WithdrawalEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } }> | null }>, winningTicketRedeemedEvents: Array<{ __typename: 'WinningTicketRedeemedEvent', id: string, faceValue: string, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number }, sender: { __typename: 'Broadcaster', id: string }, recipient: { __typename: 'Transcoder', id: string } }> }; +export type TransactionsQuery = { __typename: 'Query', transactions: Array<{ __typename: 'Transaction', events?: Array<{ __typename: 'BondEvent', additionalAmount: string, delegator: { __typename: 'Delegator', id: string }, newDelegate: { __typename: 'Transcoder', id: string }, oldDelegate?: { __typename: 'Transcoder', id: string } | null, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'BurnEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'DepositFundedEvent', amount: string, sender: { __typename: 'Broadcaster', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'EarningsClaimedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'MigrateDelegatorFinalizedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'MintEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'NewRoundEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'ParameterUpdateEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'PauseEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'PollCreatedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'RebondEvent', amount: string, delegate: { __typename: 'Transcoder', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'ReserveClaimedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'ReserveFundedEvent', amount: string, reserveHolder: { __typename: 'Broadcaster', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'RewardEvent', rewardTokens: string, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'ServiceURIUpdateEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'SetCurrentRewardTokensEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'StakeClaimedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'TranscoderActivatedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'TranscoderDeactivatedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'TranscoderEvictedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'TranscoderResignedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'TranscoderSlashedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'TranscoderUpdateEvent', rewardCut: string, feeShare: string, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'TransferBondEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'TreasuryVoteEvent', id: string, reason?: string | null, support: TreasuryVoteSupport, timestamp: number, weight: string, proposal: { __typename: 'TreasuryProposal', id: string, targets: Array, description: string }, treasuryVoter: { __typename: 'LivepeerAccount', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'UnbondEvent', amount: string, delegate: { __typename: 'Transcoder', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'UnpauseEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'VoteEvent', voter: string, choiceID: string, id: string, timestamp: number, poll: { __typename: 'Poll', id: string, proposal: string, endBlock: string, quorum: string, quota: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number }, round: { __typename: 'Round', id: string } } | { __typename: 'WinningTicketRedeemedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'WithdrawFeesEvent', amount: string, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'WithdrawStakeEvent', amount: string, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'WithdrawalEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } }> | null }>, winningTicketRedeemedEvents: Array<{ __typename: 'WinningTicketRedeemedEvent', id: string, faceValue: string, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number }, sender: { __typename: 'Broadcaster', id: string }, recipient: { __typename: 'Transcoder', id: string } }>, rewardEvents: Array<{ __typename: 'RewardEvent', id: string, rewardTokens: string, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } }> }; export type TranscoderActivatedEventsQueryVariables = Exact<{ where?: InputMaybe; @@ -10781,6 +10781,24 @@ export const TransactionsDocument = gql` id } } + rewardEvents( + orderBy: timestamp + orderDirection: desc + where: {delegate: $account} + first: $first + skip: $skip + ) { + __typename + id + round { + id + } + transaction { + id + timestamp + } + rewardTokens + } } `; diff --git a/components/HistoryView/index.tsx b/components/HistoryView/index.tsx index 25c76097..4a1c4535 100644 --- a/components/HistoryView/index.tsx +++ b/components/HistoryView/index.tsx @@ -83,6 +83,8 @@ const Index = () => { const isVoteEvent = isType("VoteEvent"); const isTreasuryVoteEvent = isType("TreasuryVoteEvent"); + // Clamps tickets/rewards to the oldest loaded transaction so rows only + // append; nothing is stranded, registration precedes both. const lastEventTimestamp = useMemo( () => Number(events?.[(events?.length || 0) - 1]?.transaction?.timestamp ?? 0), @@ -170,18 +172,30 @@ const Index = () => { })); }, [data?.winningTicketRedeemedEvents, account, lastEventTimestamp]); - // WinningTicketRedeemedEvents, VoteEvents, and TreasuryVoteEvents are replaced - // with enriched versions (direction-tagged tickets, IPFS-enriched votes) + // Orchestrator-keyed so delegated reward calls, whose transaction is sent by + // the reward caller, are included too. + const rewardEvents = useMemo( + () => + data?.rewardEvents?.filter( + (e) => (e?.transaction?.timestamp ?? 0) > lastEventTimestamp + ) ?? [], + [data?.rewardEvents, lastEventTimestamp] + ); + const mergedEvents = useMemo( () => [ + // Dropped here and re-added below from the enriched (tickets, votes) + // and superset (rewards) lists, so they are not listed twice. ...events.filter( (e) => e?.__typename !== "WinningTicketRedeemedEvent" && e?.__typename !== "TreasuryVoteEvent" && - e?.__typename !== "VoteEvent" + e?.__typename !== "VoteEvent" && + e?.__typename !== "RewardEvent" ), ...ticketEvents, + ...rewardEvents, ...extendedTreasuryVoteEventsData, ...extendedVoteEventsData, ].sort( @@ -191,6 +205,7 @@ const Index = () => { [ events, ticketEvents, + rewardEvents, extendedTreasuryVoteEventsData, extendedVoteEventsData, ] @@ -198,7 +213,8 @@ const Index = () => { const totalLoaded = Math.max( data?.transactions?.length ?? 0, - data?.winningTicketRedeemedEvents?.length ?? 0 + data?.winningTicketRedeemedEvents?.length ?? 0, + data?.rewardEvents?.length ?? 0 ); const fetchingRef = useRef(false); @@ -210,11 +226,19 @@ const Index = () => { try { await fetchMoreTransactions({ variables: { + // Shared skip: a shorter list is only over-skipped once exhausted. skip: totalLoaded, }, updateQuery: (previousResult, { fetchMoreResult }) => { if (!fetchMoreResult) return previousResult; - if (fetchMoreResult.transactions.length < PAGE_SIZE) + // Stop only once every list is exhausted. Rewards and ticket + // redemptions can be submitted by another address, so neither list is + // bounded by `transactions`. + if ( + fetchMoreResult.transactions.length < PAGE_SIZE && + fetchMoreResult.winningTicketRedeemedEvents.length < PAGE_SIZE && + fetchMoreResult.rewardEvents.length < PAGE_SIZE + ) setReachedEnd(true); return { @@ -223,13 +247,14 @@ const Index = () => { ...previousResult.transactions, ...fetchMoreResult.transactions, ], - // Basing the query skip for winning tickets on transactions.length is fine because there will always be more transactions than winning tickets - // So, we will always have winning ticket events that are older than the last transaction timestamp - // Allowing mergedEvents to filter correctly winningTicketRedeemedEvents: [ ...previousResult.winningTicketRedeemedEvents, ...fetchMoreResult.winningTicketRedeemedEvents, ], + rewardEvents: [ + ...previousResult.rewardEvents, + ...fetchMoreResult.rewardEvents, + ], }; }, }); @@ -276,7 +301,8 @@ const Index = () => { if ( !data?.transactions?.length && - !data?.winningTicketRedeemedEvents?.length + !data?.winningTicketRedeemedEvents?.length && + !data?.rewardEvents?.length ) { return No history; } diff --git a/queries/transactions.graphql b/queries/transactions.graphql index 36949a97..27644b88 100644 --- a/queries/transactions.graphql +++ b/queries/transactions.graphql @@ -132,4 +132,26 @@ query transactions($account: String!, $first: Int!, $skip: Int!) { id } } + # Keyed on the orchestrator (RewardEvent.delegate), not the transaction sender. + # Since LIP-118 a delegated reward caller may submit reward() on the + # orchestrator's behalf, so those calls are absent from the transactions query + # above, which filters on `from`. + rewardEvents( + orderBy: timestamp + orderDirection: desc + where: { delegate: $account } + first: $first + skip: $skip + ) { + __typename + id + round { + id + } + transaction { + id + timestamp + } + rewardTokens + } }