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;