Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 54 additions & 16 deletions components/StakeTransactions/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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 (
<Box css={{ marginTop: "$6" }}>
Expand Down Expand Up @@ -75,8 +114,7 @@ const Index = ({ delegator, transcoders, currentRound, isMyAccount }) => {
</Box>
<Text variant="neutral" size="1">
Tokens will be available for withdrawal in approximately{" "}
{+lock.withdrawRound - parseInt(currentRound.id, 10)}{" "}
days.
{+lock.withdrawRound - roundId} days.
</Text>
</Box>
<Flex
Expand Down
13 changes: 13 additions & 0 deletions hooks/useSwr.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
RegisteredToVote,
VotingPower,
} from "@lib/api/types/get-treasury-proposal";
import { UnbondingLocks } from "@lib/api/types/get-unbonding-locks";
import { formatAddress } from "@utils/web3";
import useSWR from "swr";
import { Address } from "viem";
Expand Down Expand Up @@ -181,6 +182,18 @@ export const useAccountBalanceData = (address: string | undefined | null) => {
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<UnbondingLocks>(
address ? `/unbonding-locks/${address.toLowerCase()}?from=${from}` : null
);

return data ?? null;
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export const useL1DelegatorData = (address: string | undefined | null) => {
const { data } = useSWR<L1Delegator>(
address ? `/l1-delegator/${address.toLowerCase()}` : null
Expand Down
15 changes: 15 additions & 0 deletions lib/api/types/get-unbonding-locks.ts
Original file line number Diff line number Diff line change
@@ -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[];
};
102 changes: 102 additions & 0 deletions pages/api/unbonding-locks/[address].tsx
Original file line number Diff line number Diff line change
@@ -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<UnbondingLocks | null>
) => {
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
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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;
Loading