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..b00ae040 --- /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.isInteger(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;