From 798adc00e5abc694954593096dd84fc2178a5ba4 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Tue, 21 Jul 2026 20:47:11 +0200 Subject: [PATCH 1/2] fix: temporarily show unbonding locks the subgraph has not indexed Stopgap while the subgraph resyncs from scratch (livepeer/subgraph#247). The withdrawal list comes from the subgraph, so locks created after indexing stalled are missing from it: an unbond appears to do nothing, and a user with no acknowledgement may reasonably unbond again, moving more stake out for a second full unbonding period. Once such a lock matures it is also withdrawable on-chain while remaining invisible in the UI. Lock ids are sequential and never reused, so anything the subgraph has not seen sits above its highest known id. Add /api/unbonding-locks/[address], which reads that tail from the contract, and append it to the subgraph's list rather than replacing it. The round comes from /api/current-round with it, since a stale round files a matured lock as pending and hides its withdraw button. Only fetched when the subgraph reports degraded and the user is viewing their own account, so a healthy subgraph behaves exactly as before, with no extra request. The scan is bounded to 200 ids so a direct call cannot ask for an unbounded read. This is a bandaid, not a feature: it duplicates state the subgraph owns and should be reverted once indexing has recovered. It also disables itself as that happens, since the ids it fetches are those above what the subgraph reports. Co-Authored-By: Claude Opus 4.8 (1M context) Co-Authored-By: CodeRabbit --- components/StakeTransactions/index.tsx | 70 ++++++++++++---- hooks/useSwr.tsx | 13 +++ lib/api/types/get-unbonding-locks.ts | 15 ++++ pages/api/unbonding-locks/[address].tsx | 102 ++++++++++++++++++++++++ 4 files changed, 184 insertions(+), 16 deletions(-) create mode 100644 lib/api/types/get-unbonding-locks.ts create mode 100644 pages/api/unbonding-locks/[address].tsx diff --git a/components/StakeTransactions/index.tsx b/components/StakeTransactions/index.tsx index 1b4dd409..264c48c8 100644 --- a/components/StakeTransactions/index.tsx +++ b/components/StakeTransactions/index.tsx @@ -2,6 +2,11 @@ import { Box, Card, Flex, Heading, Text } from "@livepeer/design-system"; import { formatLPT } from "@utils/numberFormatters"; import { formatAddress } from "@utils/web3"; import { UnbondingLock } from "apollo"; +import { + useCurrentRoundData, + useSubgraphDegraded, + useUnbondingLocksData, +} from "hooks"; import { useMemo } from "react"; import { parseEther } from "viem"; @@ -13,20 +18,54 @@ import WithdrawStake from "../WithdrawStake"; const Index = ({ delegator, transcoders, currentRound, isMyAccount }) => { const isBonded = !!delegator.delegate; - const pendingStakeTransactions = useMemo(() => { - const roundId = parseInt(currentRound.id, 10); - return delegator.unbondingLocks.filter( - (item: UnbondingLock) => - item.withdrawRound && +item.withdrawRound > roundId - ); - }, [delegator.unbondingLocks, currentRound.id]); - const completedStakeTransactions = useMemo(() => { - const roundId = parseInt(currentRound.id, 10); - return delegator.unbondingLocks.filter( - (item: UnbondingLock) => - item.withdrawRound && +item.withdrawRound <= roundId - ); - }, [delegator.unbondingLocks, currentRound.id]); + // TEMPORARY, with the subgraph outage: locks created after indexing stopped + // are missing here, so an unbond looks like it never happened. Append the + // ones above the subgraph's highest known id, read from the chain. The round + // comes from the chain too, or a stale one leaves a matured lock stuck in + // "Pending" with no withdraw button. Remove once indexing has recovered. + const degraded = useSubgraphDegraded(); + + const nextLockId = useMemo( + () => + delegator.unbondingLocks.reduce( + (next: number, lock: UnbondingLock) => + Math.max(next, lock.unbondingLockId + 1), + 0 + ), + [delegator.unbondingLocks] + ); + const missing = useUnbondingLocksData( + degraded && isMyAccount ? delegator.id : null, + nextLockId + ); + + const locks = useMemo( + () => + missing?.locks.length + ? [...delegator.unbondingLocks, ...missing.locks] + : delegator.unbondingLocks, + [delegator.unbondingLocks, missing] + ); + + const onchainRound = useCurrentRoundData(); + const roundId = Number(onchainRound?.id ?? currentRound.id); + + const pendingStakeTransactions = useMemo( + () => + locks.filter( + (item: UnbondingLock) => + item.withdrawRound && +item.withdrawRound > roundId + ), + [locks, roundId] + ); + const completedStakeTransactions = useMemo( + () => + locks.filter( + (item: UnbondingLock) => + item.withdrawRound && +item.withdrawRound <= roundId + ), + [locks, roundId] + ); return ( @@ -75,8 +114,7 @@ const Index = ({ delegator, transcoders, currentRound, isMyAccount }) => { Tokens will be available for withdrawal in approximately{" "} - {+lock.withdrawRound - parseInt(currentRound.id, 10)}{" "} - days. + {+lock.withdrawRound - roundId} days. { return data ?? null; }; +export const useUnbondingLocksData = ( + address: string | undefined | null, + from: number +) => { + // `from` skips the ids the subgraph already has. See /api/unbonding-locks. + const { data } = useSWR( + address ? `/unbonding-locks/${address.toLowerCase()}?from=${from}` : null + ); + + return data ?? null; +}; + export const useL1DelegatorData = (address: string | undefined | null) => { const { data } = useSWR( address ? `/l1-delegator/${address.toLowerCase()}` : null diff --git a/lib/api/types/get-unbonding-locks.ts b/lib/api/types/get-unbonding-locks.ts new file mode 100644 index 00000000..d5d9cf38 --- /dev/null +++ b/lib/api/types/get-unbonding-locks.ts @@ -0,0 +1,15 @@ +export type UnbondingLockInfo = { + id: string; + unbondingLockId: number; + amount: string; + withdrawRound: string; + /** + * The contract does not record which orchestrator a lock was unbonded from, + * so this is the delegator's current delegate. + */ + delegate: { id: string }; +}; + +export type UnbondingLocks = { + locks: UnbondingLockInfo[]; +}; diff --git a/pages/api/unbonding-locks/[address].tsx b/pages/api/unbonding-locks/[address].tsx new file mode 100644 index 00000000..f22b8cf2 --- /dev/null +++ b/pages/api/unbonding-locks/[address].tsx @@ -0,0 +1,102 @@ +import { getCacheControlHeader } from "@lib/api"; +import { bondingManager } from "@lib/api/abis/main/BondingManager"; +import { getBondingManagerAddress } from "@lib/api/contracts"; +import { badRequest, internalError, methodNotAllowed } from "@lib/api/errors"; +import { UnbondingLocks } from "@lib/api/types/get-unbonding-locks"; +import { l2PublicClient } from "@lib/chains"; +import { NextApiRequest, NextApiResponse } from "next"; +import { formatEther, isAddress } from "viem"; + +/** Lock ids read per request, so a caller cannot ask for an unbounded scan. */ +const MAX_SCAN = 200; + +/** + * TEMPORARY stopgap, paired with the subgraph outage: returns the delegator's + * unbonding locks from `from` onwards, so the UI can show locks created after + * the subgraph stopped indexing. Everything the subgraph already knows keeps + * coming from the subgraph. + * + * Remove this route along with its caller once indexing has recovered. + */ +const handler = async ( + req: NextApiRequest, + res: NextApiResponse +) => { + try { + const method = req.method; + + if (method === "GET") { + res.setHeader("Cache-Control", getCacheControlHeader("revalidate")); + + const { address, from } = req.query; + + if (!!address && !Array.isArray(address) && isAddress(address)) { + const bondingManagerAddress = await getBondingManagerAddress(); + + const contract = { + address: bondingManagerAddress, + abi: bondingManager, + } as const; + + // [bondedAmount, fees, delegateAddress, delegatedAmount, startRound, + // lastClaimRound, nextUnbondingLockId] + const delegator = await l2PublicClient.readContract({ + ...contract, + functionName: "getDelegator", + args: [address], + }); + const delegateAddress = delegator[2]; + const end = Number(delegator[6]); + + // Ids are sequential and never reused, so anything the subgraph has not + // seen sits above its highest known id. Always cover the newest ids, + // whatever `from` asks for, and never read more than MAX_SCAN of them. + const requested = Number(Array.isArray(from) ? from[0] : from); + const start = Math.max( + 0, + Number.isFinite(requested) ? requested : 0, + end - MAX_SCAN + ); + + const results = await l2PublicClient.multicall({ + allowFailure: false, + contracts: Array.from( + { length: Math.max(0, end - start) }, + (_, i) => ({ + ...contract, + functionName: "getDelegatorUnbondingLock" as const, + args: [address, BigInt(start + i)] as const, + }) + ), + }); + + // Withdrawing and rebonding both delete the lock, so a non-zero amount + // is what makes one open. + const locks = results + .map(([amount, withdrawRound], i) => ({ + amount, + withdrawRound, + id: start + i, + })) + .filter(({ amount }) => amount > 0n) + .map(({ amount, withdrawRound, id }) => ({ + id: `${address.toLowerCase()}-${id}`, + unbondingLockId: id, + amount: formatEther(amount), + withdrawRound: withdrawRound.toString(), + delegate: { id: delegateAddress.toLowerCase() }, + })); + + return res.status(200).json({ locks }); + } else { + return badRequest(res, "Invalid address format"); + } + } + + return methodNotAllowed(res, method ?? "unknown", ["GET"]); + } catch (err) { + return internalError(res, err); + } +}; + +export default handler; From 7b46cf02dc6fca5756b2ee3d1e6cc63832d8874d Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Tue, 21 Jul 2026 21:24:04 +0200 Subject: [PATCH 2/2] fix: reject a non-integer scan offset Number.isFinite accepts 5.5, which reached BigInt() and threw a RangeError, turning a malformed query into a 500. The route already answers 400 for a malformed address, so treat a non-integer `from` as absent instead. Co-Authored-By: Claude Opus 4.8 (1M context) Co-Authored-By: CodeRabbit --- pages/api/unbonding-locks/[address].tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pages/api/unbonding-locks/[address].tsx b/pages/api/unbonding-locks/[address].tsx index f22b8cf2..b00ae040 100644 --- a/pages/api/unbonding-locks/[address].tsx +++ b/pages/api/unbonding-locks/[address].tsx @@ -54,7 +54,7 @@ const handler = async ( const requested = Number(Array.isArray(from) ? from[0] : from); const start = Math.max( 0, - Number.isFinite(requested) ? requested : 0, + Number.isInteger(requested) ? requested : 0, end - MAX_SCAN );