diff --git a/atp-indexer/src/api/handlers/provider/details.ts b/atp-indexer/src/api/handlers/provider/details.ts index dd2812dc4..e73d79aaa 100644 --- a/atp-indexer/src/api/handlers/provider/details.ts +++ b/atp-indexer/src/api/handlers/provider/details.ts @@ -14,7 +14,8 @@ import { erc20StakedWithProvider, providerTakeRateUpdate, staked, - failedDeposit + failedDeposit, + atpPosition } from 'ponder:schema'; /** @@ -42,9 +43,18 @@ export async function handleProviderDetails(c: Context): Promise { const providerRecord = providerData[0]; - // Get provider stakes (ATP and ERC20) and take rate history - const [atpDelegations, erc20Delegations, takeRateHistory] = await Promise.all([ - db.select().from(stakedWithProvider) + // Get provider stakes (ATP and ERC20) and take rate history. ATP rows are + // LEFT-joined with `atpPosition` so we can expose the ATP beneficiary — + // the delegator-side recipient baked into the split contract — directly + // in the response. Without that join the operator-side commission flow + // can't rebuild `splitData` to call `Split.distribute`. + const [atpDelegationsRaw, erc20Delegations, takeRateHistory] = await Promise.all([ + db.select({ + row: stakedWithProvider, + beneficiary: atpPosition.beneficiary, + }) + .from(stakedWithProvider) + .leftJoin(atpPosition, eq(stakedWithProvider.atpAddress, atpPosition.address)) .where(eq(stakedWithProvider.providerIdentifier, id)) .orderBy(desc(stakedWithProvider.blockNumber), desc(stakedWithProvider.logIndex)), db.select().from(erc20StakedWithProvider) @@ -55,10 +65,15 @@ export async function handleProviderDetails(c: Context): Promise { .orderBy(desc(providerTakeRateUpdate.timestamp)) ]); - // Combine ATP and ERC20 delegations + const atpDelegations = atpDelegationsRaw.map(r => ({ ...r.row, beneficiary: r.beneficiary })); + + // Combine ATP and ERC20 delegations. The `beneficiary` we attach here is + // the address baked into the split contract: + // - ATP delegations → joined from `atpPosition.beneficiary` + // - ERC20 delegations → the staker's wallet (= `stakerAddress`) const allDelegations = [ ...atpDelegations.map(d => ({ ...d, _source: 'atp' as const })), - ...erc20Delegations.map(d => ({ ...d, _source: 'erc20' as const })) + ...erc20Delegations.map(d => ({ ...d, _source: 'erc20' as const, beneficiary: d.stakerAddress })) ]; // Build attester-withdrawer pairs from all delegations @@ -136,6 +151,10 @@ export async function handleProviderDetails(c: Context): Promise { // ATP delegations have atpAddress, ERC20 delegations don't ...(stake._source === 'atp' && 'atpAddress' in stake && { atpAddress: checksumAddress(stake.atpAddress) }), stakerAddress: checksumAddress(stake.stakerAddress), + // Delegator-side recipient on the split contract — required to + // rebuild splitData for `Split.distribute`. Nullable defensively in + // case the ATP row couldn't be joined. + beneficiary: stake.beneficiary ? checksumAddress(stake.beneficiary) : null, splitContractAddress: checksumAddress(stake.splitContractAddress), rollupAddress: checksumAddress(stake.rollupAddress), attesterAddress: checksumAddress(stake.attesterAddress), diff --git a/atp-indexer/src/api/types/provider.types.ts b/atp-indexer/src/api/types/provider.types.ts index a447cbc35..c0fda37be 100644 --- a/atp-indexer/src/api/types/provider.types.ts +++ b/atp-indexer/src/api/types/provider.types.ts @@ -29,6 +29,14 @@ export interface ProviderListResponse { export interface ProviderStake { atpAddress?: string; // Only present for ATP-based delegations stakerAddress: string; + /** + * Delegator-side recipient baked into the split contract — the address + * that receives `10000 - providerTakeRate` of the distributed rewards. + * For ATP delegations this is the ATP's beneficiary; for ERC20 wallet + * delegations it's the staker's own wallet. Nullable defensively in case + * an ATP row couldn't be joined. + */ + beneficiary: string | null; splitContractAddress: string; rollupAddress: string; attesterAddress: string; diff --git a/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverviewClaimableRewards.tsx b/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverviewClaimableRewards.tsx index d057fb0dc..ae19821a0 100644 --- a/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverviewClaimableRewards.tsx +++ b/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverviewClaimableRewards.tsx @@ -63,7 +63,15 @@ export const ATPStakingOverviewClaimableRewards = forwardRef - {/* Delegation Rewards Section */} + {/* Delegation Rewards Section — breaks the total into the two + states delegation rewards can sit in: + • pending distribute — the user's share of (rollup + + on-split) that still needs `Split.distribute` to be + called before it lands in the warehouse. + • already in warehouse — already distributed and waiting + on a `withdraw` call. + Same breakdown the operator page surfaces; helps users see + exactly which step they're on. */}
Delegation Rewards
@@ -72,6 +80,40 @@ export const ATPStakingOverviewClaimableRewards = forwardRef Earned from staking through providers
+ {totalRewards > 0n && ( +
+
+
+ + Pending distribute + + +
+
+ {formatTokenAmount(totalRewards - pendingWarehouseWithdrawal, decimals, symbol)} +
+
+
+
+ + Already in warehouse + + +
+
+ {formatTokenAmount(pendingWarehouseWithdrawal, decimals, symbol)} +
+
+
+ )}
{/* Self-Stake Rewards Section */} diff --git a/staking-dashboard/src/components/MainContent/MainContent.tsx b/staking-dashboard/src/components/MainContent/MainContent.tsx index 8227eedf7..382714c3e 100644 --- a/staking-dashboard/src/components/MainContent/MainContent.tsx +++ b/staking-dashboard/src/components/MainContent/MainContent.tsx @@ -6,6 +6,7 @@ import { WalletConnectGuard } from "@/components/WalletConnectGuard" import { WalletConnectionAlertModal } from "../WalletConnectionAlert" import { TermsAcceptanceModal } from "@/components/TermsAcceptanceModal/TermsAcceptanceModal" import { useTermsModal } from "@/contexts/TermsModalContext" +import { useConnectedOperatorIdentities } from "@/hooks/operator" /** * Main content area with tab navigation @@ -18,9 +19,17 @@ export const MainContent = () => { const [isInitialLoad, setIsInitialLoad] = useState(true) const [animateContent, setAnimateContent] = useState(false) + const { all: operatorIdentities, isLoading: isLoadingOperator, hasError: operatorDetectionError } = useConnectedOperatorIdentities() + // When the indexer query fails we can't prove they aren't an operator. Show + // the tab in that uncertain state so a real operator isn't locked out of + // the page (where they can retry). False positives for non-operators are + // fine — they'll see the error banner explaining why the list is empty. + const isOperator = !isLoadingOperator && (operatorIdentities.length > 0 || operatorDetectionError) + const getActiveTab = () => { if (location.pathname === "/" || location.pathname === "/my-position") return "my-position" if (location.pathname === "/stake" || location.pathname === "/providers" || location.pathname.startsWith("/providers/") || location.pathname === "/register-validator") return "stake" + if (location.pathname === "/operator") return "operator" return "my-position" } @@ -132,6 +141,22 @@ export const MainContent = () => { }`}> {applyHeroItalics("Stake")} + {isOperator && ( + +
+
+ {applyHeroItalics("Operator Tools")} + + )} diff --git a/staking-dashboard/src/hooks/operator/index.ts b/staking-dashboard/src/hooks/operator/index.ts new file mode 100644 index 000000000..e0ea78853 --- /dev/null +++ b/staking-dashboard/src/hooks/operator/index.ts @@ -0,0 +1,3 @@ +export * from "./useConnectedOperatorIdentities" +export * from "./useOperatorSplitContracts" +export * from "./useOperatorOnChainReads" diff --git a/staking-dashboard/src/hooks/operator/useConnectedOperatorIdentities.ts b/staking-dashboard/src/hooks/operator/useConnectedOperatorIdentities.ts new file mode 100644 index 000000000..4d44ed874 --- /dev/null +++ b/staking-dashboard/src/hooks/operator/useConnectedOperatorIdentities.ts @@ -0,0 +1,158 @@ +import { useMemo } from "react" +import { useAccount, useReadContracts } from "wagmi" +import { useQuery } from "@tanstack/react-query" +import type { Address } from "viem" +import { config } from "@/config" +import { contracts } from "@/contracts" + +export interface OperatorIdentity { + providerId: number + providerAdmin: Address + providerRewardsRecipient: Address + providerTakeRate: number +} + +interface UseConnectedOperatorIdentitiesResult { + /** Provider ids where the connected wallet is the registered `providerAdmin`. */ + asAdmin: OperatorIdentity[] + /** Provider ids where the connected wallet is the configured `providerRewardsRecipient`. */ + asRecipient: OperatorIdentity[] + /** Union of the two — any provider id the wallet has an operator-side role for. */ + all: OperatorIdentity[] + isLoading: boolean + /** True when EITHER the indexer providers list OR the on-chain configs read + * failed. Callers should treat an empty `all` paired with `hasError = true` + * as "unknown" rather than "definitely not an operator". */ + hasError: boolean + /** Manual retry hook for the underlying queries. Settles when both the + * indexer query and the on-chain configs read have re-attempted. Errors + * are surfaced via `hasError` on the next render, so callers can + * fire-and-forget the returned promise. */ + refetch: () => Promise +} + +interface ApiProviderListItem { + id: string + address?: string +} + +interface ApiProvidersResponse { + providers?: ApiProviderListItem[] +} + +async function fetchAllProviders(): Promise { + const response = await fetch(`${config.apiHost}/api/providers`) + if (!response.ok) throw new Error(`HTTP error ${response.status}`) + const data = (await response.json()) as ApiProvidersResponse + return data.providers ?? [] +} + +/** + * Resolve the connected wallet's operator-side identities across all + * registered providers. The two roles we care about: + * + * - `providerAdmin` — can call admin functions like `addKeysToProvider`. + * May NOT receive commission directly. + * - `providerRewardsRecipient` — where commission lands in the + * SplitsWarehouse after `Split.distribute`. Defaults to `providerAdmin` + * at registration but can be rotated independently, so we always trust + * the live `providerConfigurations` read over any cached snapshot. + * + * The list of `providerId`s comes from the indexer's `/api/providers` + * snapshot (full history). An earlier version walked `ProviderRegistered` + * events directly, but the event-fetching hook caps at ~200k blocks and + * silently dropped older providers — most operators in practice. + */ +export function useConnectedOperatorIdentities(): UseConnectedOperatorIdentitiesResult { + const { address } = useAccount() + + const { + data: providersList, + isLoading: isLoadingProviders, + isError: providersError, + refetch: refetchProviders, + } = useQuery({ + queryKey: ["operator-providers-list"], + queryFn: fetchAllProviders, + staleTime: 5 * 60_000, + gcTime: 10 * 60_000, + }) + + const providerIds = useMemo(() => { + if (!providersList) return [] + return providersList + .map((p) => Number(p.id)) + .filter((id) => Number.isFinite(id)) + .sort((a, b) => a - b) + }, [providersList]) + + const { + data: configs, + isLoading: isLoadingConfigs, + isError: configsError, + refetch: refetchConfigs, + } = useReadContracts({ + contracts: providerIds.map( + (id) => + ({ + abi: contracts.stakingRegistry.abi, + address: contracts.stakingRegistry.address, + functionName: "providerConfigurations", + args: [BigInt(id)], + }) as const, + ), + query: { + enabled: providerIds.length > 0 && !!address, + staleTime: 60_000, + gcTime: 60_000, + }, + }) + + const isLoading = isLoadingProviders || isLoadingConfigs + const hasError = providersError || configsError + // Both refetches surface their own outcomes via wagmi/TanStack state; we + // just need to kick them off in parallel and let the caller `void` the + // promise. Returning `Promise` rather than swallowing the chain + // keeps unhandled-rejection diagnostics clean. + const refetch = async () => { + await Promise.all([refetchProviders(), refetchConfigs()]) + } + + return useMemo(() => { + if (!address || !configs) { + return { asAdmin: [], asRecipient: [], all: [], isLoading, hasError, refetch } + } + const connected = address.toLowerCase() + const asAdmin: OperatorIdentity[] = [] + const asRecipient: OperatorIdentity[] = [] + for (let i = 0; i < providerIds.length; i++) { + const result = configs[i] + if (result?.status !== "success" || !result.result) continue + const [providerAdmin, providerTakeRate, providerRewardsRecipient] = + result.result as [Address, number | bigint, Address] + const identity: OperatorIdentity = { + providerId: providerIds[i], + providerAdmin, + providerRewardsRecipient, + providerTakeRate: Number(providerTakeRate), + } + if (providerAdmin.toLowerCase() === connected) asAdmin.push(identity) + if (providerRewardsRecipient.toLowerCase() === connected) asRecipient.push(identity) + } + + const allMap = new Map() + for (const id of [...asAdmin, ...asRecipient]) allMap.set(id.providerId, id) + return { + asAdmin, + asRecipient, + all: [...allMap.values()].sort((a, b) => a.providerId - b.providerId), + isLoading, + hasError, + refetch, + } + // `refetch` closes over the wagmi/query refetch fns; including it in + // deps would invalidate the memo every render. The memo's content + // doesn't depend on it — it's a passthrough. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [address, configs, providerIds, isLoading, hasError]) +} diff --git a/staking-dashboard/src/hooks/operator/useOperatorOnChainReads.ts b/staking-dashboard/src/hooks/operator/useOperatorOnChainReads.ts new file mode 100644 index 000000000..227a17a3c --- /dev/null +++ b/staking-dashboard/src/hooks/operator/useOperatorOnChainReads.ts @@ -0,0 +1,195 @@ +import { useMemo } from "react" +import { useReadContract, useReadContracts } from "wagmi" +import type { Address } from "viem" +import { contracts, getRollupVersions } from "@/contracts" +import { ERC20Abi } from "@/contracts/abis/ERC20" +import { SplitAbi } from "@/contracts/abis/Split" +import { SplitWarehouseAbi } from "@/contracts/abis/SplitWarehouse" +import type { CoinbaseBreakdown } from "@/hooks/rewards/rewardsTypes" + +interface UseOperatorOnChainReadsParams { + splits: Address[] + recipients: Address[] + tokenAddress: Address | undefined +} + +interface UseOperatorOnChainReadsResult { + warehouseAddress: Address | undefined + /** `getSequencerRewards(split)` per rollup, grouped by lower-cased split. */ + rollupRewardsBySplit: Map + /** `ERC20.balanceOf(split)` keyed by lower-cased split. */ + splitBalances: Map + /** `SplitsWarehouse.balanceOf(recipient, tokenId)` keyed by lower-cased recipient. */ + warehouseBalances: Map + isLoading: boolean +} + +/** + * Unified multicall for the entire operator page. All on-chain reads we need + * fit into a single `useReadContracts` (which wagmi compiles down to one + * Multicall3.aggregate3 RPC) instead of the three roundtrips the previous + * version used (one for rollup rewards, one for split balances, one for + * warehouse balances). The warehouse address still needs a separate read — + * we have to ask any one split which warehouse it points at before we can + * call `balanceOf` on the warehouse. + * + * Layout of the multicall (in order): + * + * [0 .. R*S) — getSequencerRewards(split) per (rollup, split) + * [R*S .. R*S + S) — ERC20.balanceOf(split) per split + * [R*S + S .. R*S + S + N) — SplitsWarehouse.balanceOf(recipient, tokenId) + * + * Where R = rollup count, S = split count, N = distinct-recipient count. + */ +export function useOperatorOnChainReads( + params: UseOperatorOnChainReadsParams, +): UseOperatorOnChainReadsResult { + const { splits, recipients, tokenAddress } = params + + // Resolve the warehouse via any one split's `SPLITS_WAREHOUSE()` view. + // All splits in this dashboard point at the same warehouse for a given + // chain, so reading from one is sufficient. + const anySplit = splits[0] + const { data: warehouseAddressRaw } = useReadContract({ + address: anySplit, + abi: SplitAbi, + functionName: "SPLITS_WAREHOUSE", + query: { enabled: !!anySplit }, + }) + const warehouseAddress = warehouseAddressRaw as Address | undefined + + // Same ordinal-ising as the existing per-rollup hook. + const rollups = useMemo(() => { + const versions = getRollupVersions() + if (versions.length > 0) { + return versions.map((v, i) => ({ address: v.address, version: String(i + 1) })) + } + return [{ address: contracts.rollup.address, version: undefined as string | undefined }] + }, []) + + const tokenId = tokenAddress ? BigInt(tokenAddress) : undefined + + // Build the single contracts array. Order matters — we slice by it below. + const contractsArg = useMemo(() => { + if (splits.length === 0 || !tokenAddress) return undefined + const calls: Array<{ + abi: typeof contracts.rollup.abi | typeof ERC20Abi | typeof SplitWarehouseAbi + address: Address + functionName: string + args: readonly unknown[] + }> = [] + + // 1. getSequencerRewards(split) per (rollup, split) + for (const rollup of rollups) { + for (const split of splits) { + calls.push({ + abi: contracts.rollup.abi, + address: rollup.address, + functionName: "getSequencerRewards", + args: [split], + }) + } + } + + // 2. ERC20.balanceOf(split) per split + for (const split of splits) { + calls.push({ + abi: ERC20Abi, + address: tokenAddress, + functionName: "balanceOf", + args: [split], + }) + } + + // 3. SplitsWarehouse.balanceOf(recipient, tokenId) per recipient — only + // when we know the warehouse + token. + if (warehouseAddress && tokenId !== undefined) { + for (const recipient of recipients) { + calls.push({ + abi: SplitWarehouseAbi, + address: warehouseAddress, + functionName: "balanceOf", + args: [recipient, tokenId], + }) + } + } + + return calls + }, [splits, recipients, rollups, tokenAddress, warehouseAddress, tokenId]) + + const { data: rawData, isLoading } = useReadContracts({ + // The cast is necessary because the array of heterogeneous ABIs widens + // to a union that wagmi's generic infers as `any` — viem still encodes + // each call against its own ABI correctly at runtime. + contracts: contractsArg as never, + query: { + enabled: !!contractsArg, + refetchInterval: 30_000, + staleTime: 15_000, + }, + }) + // wagmi widens the heterogeneous-contracts overload's data type to `never`; + // reify here once so the slicers below stay readable. + const data = rawData as + | Array<{ status: "success"; result: unknown } | { status: "failure"; error: unknown }> + | undefined + + const rollupRewardsBySplit = useMemo(() => { + const out = new Map() + if (!data || splits.length === 0) return out + let cursor = 0 + for (const rollup of rollups) { + for (const split of splits) { + const result = data[cursor++] + const rewards = + result?.status === "success" && typeof result.result === "bigint" ? result.result : 0n + const key = split.toLowerCase() + const list = out.get(key) ?? [] + list.push({ + address: split, + rewards, + source: "manual", + rollupAddress: rollup.address, + rollupVersion: rollup.version, + }) + out.set(key, list) + } + } + return out + }, [data, splits, rollups]) + + const splitBalances = useMemo(() => { + const out = new Map() + if (!data || splits.length === 0) return out + const offset = rollups.length * splits.length + for (let i = 0; i < splits.length; i++) { + const result = data[offset + i] + const value = + result?.status === "success" && typeof result.result === "bigint" ? result.result : 0n + out.set(splits[i].toLowerCase(), value) + } + return out + }, [data, splits, rollups]) + + const warehouseBalances = useMemo(() => { + const out = new Map() + if (!data || splits.length === 0 || recipients.length === 0) return out + if (!warehouseAddress || tokenId === undefined) return out + const offset = rollups.length * splits.length + splits.length + for (let i = 0; i < recipients.length; i++) { + const result = data[offset + i] + const value = + result?.status === "success" && typeof result.result === "bigint" ? result.result : 0n + out.set(recipients[i].toLowerCase(), value) + } + return out + }, [data, splits, recipients, rollups, warehouseAddress, tokenId]) + + return { + warehouseAddress, + rollupRewardsBySplit, + splitBalances, + warehouseBalances, + isLoading, + } +} diff --git a/staking-dashboard/src/hooks/operator/useOperatorSplitContracts.ts b/staking-dashboard/src/hooks/operator/useOperatorSplitContracts.ts new file mode 100644 index 000000000..0b318fc6d --- /dev/null +++ b/staking-dashboard/src/hooks/operator/useOperatorSplitContracts.ts @@ -0,0 +1,179 @@ +import { useMemo } from "react" +import { useQueries } from "@tanstack/react-query" +import { isAddressEqual, type Address } from "viem" +import { config } from "@/config" +import type { OperatorIdentity } from "./useConnectedOperatorIdentities" + +/** + * One split contract that routes commission to a particular provider. The + * delegator-side beneficiary is captured at stake time and IS required to + * rebuild the `splitData.recipients` tuple for a distribute call — but it is + * NOT required to merely read what's accumulating on the split. If the + * indexer doesn't expose a beneficiary for a particular stake (currently + * ERC20 wallet delegations come through without an `atp` object), we still + * surface the split so its rollup balances are visible; the distribute step + * is gated separately. + */ +export interface OperatorSplitContract { + splitContract: Address + /** Provider id this split is configured against. */ + providerId: number + /** The provider's configured rewards recipient at the time we read it. */ + providerRewardsRecipient: Address + providerTakeRate: number + /** The delegator-side recipient stored on the split, when the indexer + * surfaces it. Distribute calls are skipped for splits where this is + * undefined. */ + delegatorBeneficiary?: Address + /** Display label sourced from the indexer's provider name (or id fallback). */ + providerLabel: string + /** Indexer source — useful for debugging "why is THIS split here" UX. */ + source: "atp-delegation" | "erc20-delegation" | "unknown" +} + +interface ProviderStakeRow { + splitContractAddress?: string + stakerAddress?: string + /** Direct beneficiary field from the indexer (added Nov 2026). For ATP + * delegations this is the joined ATP beneficiary; for ERC20 wallet + * delegations the indexer fills it with the staker's wallet. */ + beneficiary?: string | null + attesterAddress?: string + rollupAddress?: string + atpAddress?: string + source?: "atp" | "erc20" + /** Legacy nested shape; kept as a fallback while older indexer builds are + * still in rotation. Will go away once every env is past the join fix. */ + atp?: { beneficiary?: string } | null +} + +interface ProviderDetailResponse { + id: string + name?: string + stakes?: ProviderStakeRow[] + // Some indexer responses split ATP and ERC20 delegations into separate + // arrays — accept both shapes so we don't silently miss either. + erc20Stakes?: ProviderStakeRow[] + erc20DelegationBreakdown?: ProviderStakeRow[] +} + +async function fetchProviderDetail(providerId: number): Promise { + const response = await fetch(`${config.apiHost}/api/providers/${providerId}`) + if (!response.ok) { + throw new Error(`Failed to fetch provider ${providerId}: ${response.status}`) + } + return response.json() +} + +/** + * Collect every split contract that pays the connected operator across the + * supplied identities. One row per `(splitContract, providerId)` pair — + * different providers can in theory share a split address (they don't in + * practice, but it's the deduplication key the cart will eventually need). + * + * Filter philosophy: discover broadly, gate narrowly. We surface every split + * with a non-zero address so the user can SEE rollup-side rewards + * accumulating. The distribute step inspects `delegatorBeneficiary` per-row + * and skips entries that don't have one rather than dropping the whole row. + */ +export function useOperatorSplitContracts(identities: OperatorIdentity[]) { + const queries = useQueries({ + queries: identities.map((identity) => ({ + queryKey: ["operator-provider-detail", identity.providerId], + queryFn: () => fetchProviderDetail(identity.providerId), + staleTime: 60_000, + gcTime: 5 * 60_000, + })), + }) + + const isLoading = queries.some((q) => q.isLoading) + const errors = queries.map((q) => q.error).filter((e): e is Error => e !== null) + const hasErrors = errors.length > 0 + const refetch = () => Promise.all(queries.map((q) => q.refetch())) + + // `queries` is a fresh array reference on every render even when the + // underlying TanStack Query state hasn't changed — depending on it + // directly invalidates the memo every render, cascading through + // splitContracts → splitAddresses → distinctRecipients → useOperatorOnChainReads + // and rebuilding contractsArg / re-running its `useMemo` chain on every + // render. We collapse the meaningful inputs into a primitive `dataKey` + // built from each query's `dataUpdatedAt` timestamp; that string only + // changes when the data underneath actually changes, so the memo (and + // every downstream memo that depends on splitContracts) gets a stable + // reference until something real updates. + const dataKey = queries.map((q) => q.dataUpdatedAt ?? 0).join(",") + + const splitContracts = useMemo(() => { + const out: OperatorSplitContract[] = [] + const seen = new Set() + for (let i = 0; i < identities.length; i++) { + const identity = identities[i] + const data = queries[i]?.data + if (!data) continue + const label = data.name?.trim() || `Provider ${identity.providerId}` + + // Walk every plausible stake bucket the indexer might use. + const buckets: Array<{ rows: ProviderStakeRow[] | undefined; source: OperatorSplitContract["source"] }> = [ + { rows: data.stakes, source: "atp-delegation" }, + { rows: data.erc20Stakes, source: "erc20-delegation" }, + { rows: data.erc20DelegationBreakdown, source: "erc20-delegation" }, + ] + + for (const { rows, source } of buckets) { + if (!rows) continue + for (const stake of rows) { + const splitAddress = stake.splitContractAddress as Address | undefined + if (!splitAddress) continue + + // Resolve the delegator beneficiary in this preference order: + // 1. The top-level `beneficiary` field added by the indexer's + // provider/details JOIN (post-fix). + // 2. The legacy nested `atp.beneficiary` field (transitional). + // 3. `stakerAddress` when the indexer marks the row as ERC20 — + // for wallet delegations the staker IS the beneficiary. + let beneficiary: Address | undefined + if (stake.beneficiary) { + beneficiary = stake.beneficiary as Address + } else if (stake.atp?.beneficiary) { + beneficiary = stake.atp.beneficiary as Address + } else if ((stake.source === "erc20" || source === "erc20-delegation") && stake.stakerAddress) { + beneficiary = stake.stakerAddress as Address + } + + const key = `${identity.providerId}:${splitAddress.toLowerCase()}` + if (seen.has(key)) continue + seen.add(key) + + // Skip degenerate self-splits (operator == delegator). + if (beneficiary && isAddressEqual(splitAddress, beneficiary)) continue + + // Source comes straight from the indexer when provided; otherwise + // fall back to the bucket the row was found in. + const inferredSource: OperatorSplitContract["source"] = + stake.source === "atp" + ? "atp-delegation" + : stake.source === "erc20" + ? "erc20-delegation" + : source + + out.push({ + splitContract: splitAddress, + providerId: identity.providerId, + providerRewardsRecipient: identity.providerRewardsRecipient, + providerTakeRate: identity.providerTakeRate, + delegatorBeneficiary: beneficiary, + providerLabel: label, + source: inferredSource, + }) + } + } + } + return out + // `queries` is accessed inside the closure but intentionally excluded + // from the dep list: `dataKey` already captures whether anything + // meaningful changed. See the comment above its declaration. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [identities, dataKey]) + + return { splitContracts, isLoading, hasErrors, errors, refetch } +} diff --git a/staking-dashboard/src/hooks/transactionCart/useMulticall3Execution.ts b/staking-dashboard/src/hooks/transactionCart/useMulticall3Execution.ts index 63dedd8f5..61cc39119 100644 --- a/staking-dashboard/src/hooks/transactionCart/useMulticall3Execution.ts +++ b/staking-dashboard/src/hooks/transactionCart/useMulticall3Execution.ts @@ -31,16 +31,13 @@ interface UseMulticall3ExecutionProps { setCurrentExecutingId: React.Dispatch> } -/** - * Hard upper bound on the number of inner calls we'll consider for batching - * in a SINGLE multicall before chunking. Acts as a sanity bound on cart size; - * the actual per-tx size is gas-driven (see `BLOCK_GAS_FRACTION` below). - * - * Bumped from 64 to 256 to accommodate operator-side carts where one operator - * may aggregate dozens of delegators × multiple rollups; the gas-aware chunker - * splits the work safely regardless. - */ -const MAX_BATCH_SIZE = 256 +// Previously we enforced a hard cap of 256 inner calls per cart for batching. +// That cap kept rejecting legitimate operator-side carts (one operator × many +// delegators × multiple rollups easily produces >256 entries). The gas-aware +// chunker (`chunkByGasLimit`) already splits a large input into multiple +// signatures sized against the live block gas limit, so the upstream cap was +// the wrong place to enforce sanity — it tripped before the chunker ever ran. +// The cap is intentionally removed; see `chunkByGasLimit` for the real bound. /** * Fraction of the chain's current block gas limit we're willing to fill in a @@ -83,13 +80,14 @@ export function isEntryMulticall3Eligible(tx: CartTransaction): boolean { * * 1. Every entry must pass `isEntryMulticall3Eligible`. * 2. Batch size > 1 (single-entry carts don't benefit from wrapping). - * 3. Batch size <= MAX_BATCH_SIZE (defensive bound; the gas-aware chunker - * handles real sizing, but capping here prevents pathological carts - * from queuing endless RPC estimations). + * + * There is no upper bound here on purpose — `chunkByGasLimit` splits an + * over-large batch into multiple signatures sized against the live block + * gas limit. Capping here would (and did) prevent legitimate operator-side + * carts of 250+ entries from batching at all. */ export function isMulticall3Eligible(pendingTransactions: CartTransaction[]): boolean { if (pendingTransactions.length <= 1) return false - if (pendingTransactions.length > MAX_BATCH_SIZE) return false return pendingTransactions.every(isEntryMulticall3Eligible) } diff --git a/staking-dashboard/src/pages/Operator/OperatorPage.tsx b/staking-dashboard/src/pages/Operator/OperatorPage.tsx new file mode 100644 index 000000000..d4737438b --- /dev/null +++ b/staking-dashboard/src/pages/Operator/OperatorPage.tsx @@ -0,0 +1,739 @@ +import { useMemo, useState } from "react" +import { useAccount } from "wagmi" +import { type Address } from "viem" +import { PageHeader } from "@/components/PageHeader" +import { Icon } from "@/components/Icon" +import { CopyButton } from "@/components/CopyButton" +import { TooltipIcon } from "@/components/Tooltip" +import { useStakingAssetTokenDetails } from "@/hooks/stakingRegistry" +import { useIsRewardsClaimable } from "@/hooks/rollup/useIsRewardsClaimable" +import { + useConnectedOperatorIdentities, + useOperatorSplitContracts, + useOperatorOnChainReads, + type OperatorIdentity, + type OperatorSplitContract, +} from "@/hooks/operator" +import { useTransactionCart } from "@/contexts/TransactionCartContext" +import { useAlert } from "@/contexts/AlertContext" +import { + buildOperatorCommissionEntries, + buildOperatorWarehouseWithdrawEntry, + type OperatorSplitInputs, +} from "@/utils/operatorCommissionCart" +import { formatTokenAmountFull } from "@/utils/atpFormatters" +import { formatBipsToPercentage } from "@/utils/formatNumber" +import type { CoinbaseBreakdown } from "@/hooks/rewards/rewardsTypes" + +/** + * Operator commission claim page. Visible only when the connected wallet is + * `providerAdmin` or `providerRewardsRecipient` for at least one provider — + * the navbar gates the link via the same detection hook, and we re-check + * here so a deep-link or page reload still shows the empty/redirect state + * cleanly. + * + * Commission lives in two places at any given moment: + * + * 1. **Pre-distribute** — tokens on a rollup waiting to be claimed + * (`getSequencerRewards(split)`) or already claimed but sitting on the + * split contract pre-distribute (`ERC20.balanceOf(split)`). These are + * per-split. The operator's share is `total * providerTakeRate / 10000`. + * + * 2. **Post-distribute** — already split and credited in the SplitsWarehouse + * keyed by `providerRewardsRecipient`. This is per-RECIPIENT, not + * per-split: many splits often share one recipient (`providerAdmin` + * defaults to recipient at registration). Reading the warehouse balance + * per-row double-counts when N splits → 1 recipient, so we read it + * once per distinct recipient and surface it as its own card. + */ +export default function OperatorPage() { + const { address } = useAccount() + const { + all, + asAdmin, + asRecipient, + isLoading: isLoadingIdentities, + hasError: identitiesError, + refetch: refetchIdentities, + } = useConnectedOperatorIdentities() + const { symbol, decimals, stakingAssetAddress: tokenAddress } = useStakingAssetTokenDetails() + const { isRewardsClaimable } = useIsRewardsClaimable() + // Cosmetic filter — historical delegations are kept in the underlying data + // (a now-exited delegator might still have unclaimed rollup rewards on the + // split), but a fully-drained row adds noise without action. Default to + // hiding so operators see actionable rows first. + const [hideEmptySplits, setHideEmptySplits] = useState(true) + + // Splits paying any of our identities (one row per delegator × provider). + const { + splitContracts, + isLoading: isLoadingSplits, + hasErrors: splitsHaveErrors, + refetch: refetchSplits, + } = useOperatorSplitContracts(all) + + const indexerError = identitiesError || splitsHaveErrors + // Fire-and-forget: refetch outcomes propagate through `hasError`/`hasErrors` + // on the next render. We `void` the combined promise rather than awaiting + // (the click handler is synchronous) and add a no-op `.catch` so a + // rejection — should both refetches' retry budgets exhaust — doesn't + // surface as an unhandled-rejection in tooling. + const retryIndexer = () => { + void Promise.all([refetchIdentities(), refetchSplits()]).catch(() => {}) + } + + const splitAddresses = useMemo( + () => splitContracts.map((s) => s.splitContract), + [splitContracts], + ) + const distinctRecipients = useMemo(() => { + const set = new Map() + for (const s of splitContracts) set.set(s.providerRewardsRecipient.toLowerCase(), s.providerRewardsRecipient) + return [...set.values()] + }, [splitContracts]) + + // ONE multicall for everything we need: rollup rewards per split, ERC20 + // balance on each split, and warehouse balance per distinct recipient. + const { + warehouseAddress, + rollupRewardsBySplit, + splitBalances, + warehouseBalances, + isLoading: isLoadingChainReads, + } = useOperatorOnChainReads({ + splits: splitAddresses, + recipients: distinctRecipients, + tokenAddress, + }) + + // Totals — explicitly separated so the UI can show pre-distribute and + // warehouse balances without ever double-counting recipient-level money. + const totals = useMemo(() => { + let pendingDistribute = 0n + for (const s of splitContracts) { + const rollupTotal = (rollupRewardsBySplit.get(s.splitContract.toLowerCase()) ?? []).reduce( + (sum, r) => sum + r.rewards, + 0n, + ) + const onSplit = splitBalances.get(s.splitContract.toLowerCase()) ?? 0n + pendingDistribute += ((rollupTotal + onSplit) * BigInt(s.providerTakeRate)) / 10000n + } + let inWarehouse = 0n + for (const balance of warehouseBalances.values()) inWarehouse += balance + return { pendingDistribute, inWarehouse, total: pendingDistribute + inWarehouse } + }, [splitContracts, rollupRewardsBySplit, splitBalances, warehouseBalances]) + + return ( + <> + + + + + {indexerError && ( +
+ + Indexer request failed. The list below may be incomplete or out of date — totals computed from on-chain reads are still accurate for whichever splits did load. + + +
+ )} + + {!isRewardsClaimable && ( +
+ Rewards are currently locked on the network. You can queue claims now and execute them once rewards unlock. +
+ )} + + 0 && !!tokenAddress && !!warehouseAddress} + onAddAll={() => { + if (!tokenAddress || !warehouseAddress) return null + return { + tokenAddress, + warehouseAddress, + inputs: splitContracts.map((s) => ({ + splitContract: s.splitContract, + providerRewardsRecipient: s.providerRewardsRecipient, + delegatorBeneficiary: s.delegatorBeneficiary, + providerTakeRate: s.providerTakeRate, + providerLabel: s.providerLabel, + rollupRewardsByRollup: (rollupRewardsBySplit.get(s.splitContract.toLowerCase()) ?? []) + .filter((r) => r.rewards > 0n) + .map((r) => ({ + rollupAddress: r.rollupAddress, + rollupVersion: r.rollupVersion ?? "?", + rewards: r.rewards, + })), + splitContractBalance: splitBalances.get(s.splitContract.toLowerCase()) ?? 0n, + tokenAddress, + decimals: decimals ?? 18, + symbol: symbol ?? "", + })), + } + }} + /> + + + + setHideEmptySplits((v) => !v)} + /> + + ) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Subcomponents +// ───────────────────────────────────────────────────────────────────────────── + +interface IdentitiesSummaryProps { + address: Address | undefined + asAdmin: OperatorIdentity[] + asRecipient: OperatorIdentity[] + isLoading: boolean +} + +function IdentitiesSummary({ address, asAdmin, asRecipient, isLoading }: IdentitiesSummaryProps) { + if (isLoading) { + return ( +
+ Resolving operator identities… +
+ ) + } + if (asAdmin.length === 0 && asRecipient.length === 0) { + return ( +
+

+ The connected wallet ({address ? `${address.slice(0, 10)}…${address.slice(-8)}` : "—"}) isn't registered as the admin or rewards recipient for any provider. +

+
+ ) + } + return ( +
+
+ {asAdmin.length > 0 && ( +
+ Admin of + + {asAdmin.map((i) => `#${i.providerId}`).join(", ")} + +
+ )} + {asRecipient.length > 0 && ( +
+ Rewards recipient for + + {asRecipient.map((i) => `#${i.providerId}`).join(", ")} + +
+ )} +
+
+ ) +} + +interface TotalsCardProps { + totals: { pendingDistribute: bigint; inWarehouse: bigint; total: bigint } + decimals: number + symbol: string + canBatch: boolean + onAddAll: () => + | { tokenAddress: Address; warehouseAddress: Address; inputs: OperatorSplitInputs[] } + | null +} + +function TotalsCard({ totals, decimals, symbol, canBatch, onAddAll }: TotalsCardProps) { + const { addTransaction, openCart } = useTransactionCart() + const { showAlert } = useAlert() + + const handleAddAll = () => { + const payload = onAddAll() + if (!payload) { + showAlert("error", "Token or warehouse address unavailable") + return + } + const entries = buildOperatorCommissionEntries({ + splits: payload.inputs, + warehouseAddress: payload.warehouseAddress, + tokenAddress: payload.tokenAddress, + }) + if (entries.length === 0) { + showAlert("info", "Nothing to claim right now") + return + } + for (const entry of entries) { + addTransaction(entry, { preventDuplicate: true }) + } + openCart() + } + + return ( +
+
+
+
+ + Total ready to claim + + +
+
+ {formatTokenAmountFull(totals.total, decimals, symbol)} +
+
+
+
+ + Pending distribute + + +
+
+ {formatTokenAmountFull(totals.pendingDistribute, decimals, symbol)} +
+
+
+
+ + Already in warehouse + + +
+
+ {formatTokenAmountFull(totals.inWarehouse, decimals, symbol)} +
+
+
+ +
+ ) +} + +interface WarehouseSectionProps { + warehouseAddress: Address | undefined + recipients: Address[] + balances: Map + tokenAddress: Address | undefined + decimals: number + symbol: string + isLoading: boolean +} + +function WarehouseSection({ + warehouseAddress, + recipients, + balances, + tokenAddress, + decimals, + symbol, + isLoading, +}: WarehouseSectionProps) { + const { addTransaction, openCart, checkStepGroupInQueue } = useTransactionCart() + const { showAlert } = useAlert() + + // Only show recipients with a non-zero balance. + const rows = recipients + .map((r) => ({ recipient: r, balance: balances.get(r.toLowerCase()) ?? 0n })) + .filter((r) => r.balance > 0n) + + if (isLoading || rows.length === 0) return null + + const handleWithdraw = (recipient: Address) => { + if (!tokenAddress || !warehouseAddress) { + showAlert("error", "Token or warehouse address unavailable") + return + } + // Standalone warehouse withdraw — no upstream distribute to depend on + // because the balance was distributed previously (likely by a delegator + // calling their own claim path). + const entry = buildOperatorWarehouseWithdrawEntry({ + warehouseAddress, + providerRewardsRecipient: recipient, + tokenAddress, + dependsOnDistributeGroups: [], + }) + addTransaction(entry, { preventDuplicate: true }) + openCart() + } + + return ( +
+
+ Ready to withdraw from warehouse +
+
+ {rows.map(({ recipient, balance }) => { + const isQueued = + warehouseAddress !== undefined && + checkStepGroupInQueue( + "claim:split-withdraw", + `operator-warehouse:${warehouseAddress.toLowerCase()}:${recipient.toLowerCase()}`, + ) + return ( +
+
+
Recipient
+
+ + {recipient.slice(0, 10)}…{recipient.slice(-8)} + + +
+
+
+
+
+ Balance +
+
+ {formatTokenAmountFull(balance, decimals, symbol)} +
+
+ {isQueued ? ( + + ) : ( + + )} +
+
+ ) + })} +
+
+ ) +} + +interface SplitsListProps { + splitContracts: OperatorSplitContract[] + rollupRewardsBySplit: Map + splitBalances: Map + warehouseAddress: Address | undefined + tokenAddress: Address | undefined + decimals: number + symbol: string + isLoading: boolean + hideEmptySplits: boolean + onToggleHideEmpty: () => void +} + +/** + * Dust threshold for the "Hide empty splits" filter — matches the + * delegator-claim path's `RECOVERY_DUST_THRESHOLD_NUMERATOR = 5n`. Anything + * below 0.5 of one whole token renders as "0" in `formatTokenAmountFull` + * (which uses `Math.round(Number(formatUnits(...)))`), so without this + * threshold operators see a list of rows that all display zero but pass + * the strict `> 0n` check. + */ +const DUST_THRESHOLD_NUMERATOR = 5n +function dustThresholdFor(decimals: number): bigint { + return decimals >= 1 ? DUST_THRESHOLD_NUMERATOR * 10n ** BigInt(decimals - 1) : 0n +} + +function SplitsList({ + splitContracts, + rollupRewardsBySplit, + splitBalances, + warehouseAddress, + tokenAddress, + decimals, + symbol, + isLoading, + hideEmptySplits, + onToggleHideEmpty, +}: SplitsListProps) { + // A row counts as "empty" when its pre-distribute pool is below the + // dust threshold (≈ half a token). Strict `> 0n` would still surface + // rows with a few wei that render as "0" — that's the bug operators + // were seeing. Warehouse money is tracked at the recipient level above + // and doesn't gate per-split visibility here. + const dust = useMemo(() => dustThresholdFor(decimals), [decimals]) + const visibleSplits = useMemo(() => { + if (!hideEmptySplits) return splitContracts + return splitContracts.filter((s) => { + const rollupTotal = (rollupRewardsBySplit.get(s.splitContract.toLowerCase()) ?? []).reduce( + (sum, r) => sum + r.rewards, + 0n, + ) + const onSplit = splitBalances.get(s.splitContract.toLowerCase()) ?? 0n + return rollupTotal + onSplit >= dust + }) + }, [splitContracts, rollupRewardsBySplit, splitBalances, hideEmptySplits, dust]) + + if (isLoading) { + return ( +
+ +
+ ) + } + if (splitContracts.length === 0) { + return ( +
+ +

No splits found for this operator.

+

+ Splits are created when delegators stake to your provider. +

+
+ ) + } + + const hiddenCount = splitContracts.length - visibleSplits.length + + return ( +
+
+
+ Splits paying you ({visibleSplits.length} + {hiddenCount > 0 ? ` of ${splitContracts.length}` : ""}) +
+ +
+ {visibleSplits.length === 0 ? ( +
+

All splits are currently empty.

+

+ Uncheck "Hide empty splits" to view all {splitContracts.length} historical splits. +

+
+ ) : ( + visibleSplits.map((s) => ( + + )) + )} +
+ ) +} + +interface SplitRowProps { + split: OperatorSplitContract + rollupRewards: CoinbaseBreakdown[] + splitContractBalance: bigint + warehouseAddress: Address | undefined + tokenAddress: Address | undefined + decimals: number + symbol: string +} + +function SplitRow({ + split, + rollupRewards, + splitContractBalance, + warehouseAddress, + tokenAddress, + decimals, + symbol, +}: SplitRowProps) { + const { addTransaction, openCart, checkStepGroupInQueue } = useTransactionCart() + const { showAlert } = useAlert() + + const rollupTotal = rollupRewards.reduce((sum, r) => sum + r.rewards, 0n) + const preDistribute = rollupTotal + splitContractBalance + // Pending commission for THIS split only — warehouse balance lives in the + // recipient-level WarehouseSection above and is intentionally excluded + // here to avoid double-counting across splits sharing one recipient. + const pendingCommission = (preDistribute * BigInt(split.providerTakeRate)) / 10000n + const hasWork = rollupRewards.some((r) => r.rewards > 0n) || splitContractBalance > 0n + + const isQueued = checkStepGroupInQueue( + "claim:split-distribute", + `operator-commission:${split.splitContract.toLowerCase()}`, + ) + + const handleAdd = () => { + if (!tokenAddress || !warehouseAddress) { + showAlert("error", "Token or warehouse address unavailable") + return + } + const entries = buildOperatorCommissionEntries({ + splits: [ + { + splitContract: split.splitContract, + providerRewardsRecipient: split.providerRewardsRecipient, + delegatorBeneficiary: split.delegatorBeneficiary, + providerTakeRate: split.providerTakeRate, + providerLabel: split.providerLabel, + rollupRewardsByRollup: rollupRewards + .filter((r) => r.rewards > 0n) + .map((r) => ({ + rollupAddress: r.rollupAddress, + rollupVersion: r.rollupVersion ?? "?", + rewards: r.rewards, + })), + splitContractBalance, + tokenAddress, + decimals, + symbol, + }, + ], + warehouseAddress, + tokenAddress, + }) + if (entries.length === 0) { + showAlert("info", "Nothing to claim for this split") + return + } + for (const entry of entries) { + addTransaction(entry, { preventDuplicate: true }) + } + openCart() + } + + return ( +
+
+
+
+ + + {split.providerLabel} + + + {formatBipsToPercentage(split.providerTakeRate)}% take + + #{split.providerId} +
+
+ + {split.splitContract.slice(0, 10)}…{split.splitContract.slice(-8)} + + +
+
+ {split.delegatorBeneficiary ? ( + <> + Delegator:{" "} + + {split.delegatorBeneficiary.slice(0, 10)}…{split.delegatorBeneficiary.slice(-8)} + + + ) : ( + + Delegator address not indexed — distribute step skipped, only rollup claim is bundled. + + )} +
+
+ +
+
+
+ Pending commission +
+
+ {formatTokenAmountFull(pendingCommission, decimals, symbol)} +
+
+ From {formatTokenAmountFull(preDistribute, decimals, symbol)} pre-distribute +
+
+ {isQueued ? ( + + ) : ( + + )} +
+
+
+ ) +} diff --git a/staking-dashboard/src/pages/Operator/index.ts b/staking-dashboard/src/pages/Operator/index.ts new file mode 100644 index 000000000..e66aa7e14 --- /dev/null +++ b/staking-dashboard/src/pages/Operator/index.ts @@ -0,0 +1 @@ +export { default as OperatorPage } from "./OperatorPage" diff --git a/staking-dashboard/src/routes/AppRoutes.tsx b/staking-dashboard/src/routes/AppRoutes.tsx index d2d4f2c35..efcdc6a74 100644 --- a/staking-dashboard/src/routes/AppRoutes.tsx +++ b/staking-dashboard/src/routes/AppRoutes.tsx @@ -6,7 +6,30 @@ import { MyPositionPage } from "../pages/ATP" import { RegisterValidatorPage } from "../pages/RegisterValidator" import { StakingProvidersPage, StakingProviderDetailPage } from "../pages/Providers" import StakePortal from "@/pages/StakePortal/StakePortal" +import { OperatorPage } from "@/pages/Operator" import { NotFoundPage } from "@/pages/NotFound/NotFoundPage" +import { useConnectedOperatorIdentities } from "@/hooks/operator" + +/** + * Route guard for `/operator`. Renders the page only for wallets that have + * a confirmed operator identity (admin or rewards recipient on at least + * one provider). Anyone else — including a previously-operator wallet + * after switching to a non-operator one — is redirected to the default + * position view. We wait for the identity query to settle before bouncing + * to avoid a transient redirect on the operator's own first paint. + * + * One subtlety: when the indexer query FAILS we cannot prove the wallet + * isn't an operator (we just don't know). Bouncing to `/` in that case + * would hide a real operator from their own page AND from the retry + * banner the page renders. Pass them through; the page surfaces the + * error + retry button. + */ +function OperatorRouteGuard() { + const { all, isLoading, hasError } = useConnectedOperatorIdentities() + if (isLoading) return null + if (all.length === 0 && !hasError) return + return +} export default function AppRoutes() { return ( @@ -18,6 +41,7 @@ export default function AppRoutes() { } /> } /> } /> + } /> {/* Governance is disabled - redirect to home */} } /> diff --git a/staking-dashboard/src/utils/operatorCommissionCart.ts b/staking-dashboard/src/utils/operatorCommissionCart.ts new file mode 100644 index 000000000..f2ea5ff57 --- /dev/null +++ b/staking-dashboard/src/utils/operatorCommissionCart.ts @@ -0,0 +1,257 @@ +/** + * Cart-entry builders for operators claiming their commission. Same primitives + * as the delegator path (`buildDelegationClaimEntries`), but the trailing + * warehouse withdraw is wired to the OPERATOR's `providerRewardsRecipient` + * instead of the connected wallet. That's the only address change required — + * `Split.distribute` is permissionless and routes commission based on the + * split's own `recipients` tuple, not on `msg.sender`. + * + * Notes: + * + * - `splitData.recipients` must match what the split was created with + * (`[providerRewardsRecipient, delegatorBeneficiary]` with allocations + * `[providerTakeRate, 10000 - providerTakeRate]`). The Splits contract + * verifies the supplied tuple matches a precomputed hash; an incorrect + * tuple reverts the call. + * - There's no dust threshold here. Even small amounts are real revenue for + * an operator and worth a distribute. + * - Per (provider, recipient) there's exactly ONE warehouse withdraw at the + * end. Multiple providers can share a `providerRewardsRecipient`, so the + * caller MUST dedupe by recipient when stitching multi-provider carts. + */ + +import { + ClaimStepType, + type ClaimMetadata, + type TransactionDependency, +} from "@/contexts/TransactionCartContextType" +import type { CartTransaction } from "@/contexts/TransactionCartContext" +import { + buildClaimSequencerRewardsTx, + buildWarehouseWithdrawEntry, + type ClaimCartEntry, +} from "@/utils/claimCart" +import { buildDistributeRewardsTx } from "@/hooks/splits/useDistributeRewards" +import { formatTokenAmountFull } from "@/utils/atpFormatters" +import type { Address } from "viem" + +export interface OperatorSplitInputs { + splitContract: Address + providerRewardsRecipient: Address + /** When undefined, the distribute step is skipped (we can't rebuild + * splitData without the delegator-side recipient). Per-rollup claims and + * any pre-existing warehouse balance are still bundled. */ + delegatorBeneficiary?: Address + providerTakeRate: number + providerLabel: string + /** Per-rollup unclaimed `getSequencerRewards(splitContract)` balances. */ + rollupRewardsByRollup: Array<{ rollupAddress: Address; rollupVersion: string; rewards: bigint }> + /** Current ERC20 balance sitting on the split contract (pre-distribute). */ + splitContractBalance: bigint + tokenAddress: Address + decimals: number + symbol: string +} + +export interface OperatorSplitResult { + entries: ClaimCartEntry[] + /** `stepGroupIdentifier` of the distribute step for this split, or null when + * no work was queued (everything already swept). Pass through to the + * caller's warehouse-withdraw dependency graph. */ + distributeGroup: string | null +} + +/** + * One split's claim leg from the operator side: a `claimSequencerRewards` + * entry per rollup with a non-zero balance, then a single `distribute` to + * push tokens into the warehouse. The withdraw step is intentionally NOT + * emitted here — the caller bundles every distribute into one withdraw per + * `providerRewardsRecipient` via `buildOperatorWarehouseWithdrawEntry`. + * + * Skips the whole split if it has no rollup-claimables AND no on-split + * balance — there's nothing to distribute. + */ +export function buildOperatorSplitEntries(inputs: OperatorSplitInputs): OperatorSplitResult { + const { + splitContract, + providerRewardsRecipient, + delegatorBeneficiary, + providerTakeRate, + providerLabel, + rollupRewardsByRollup, + splitContractBalance, + tokenAddress, + decimals, + symbol, + } = inputs + + const claimables = rollupRewardsByRollup.filter((r) => r.rewards > 0n) + if (claimables.length === 0 && splitContractBalance === 0n) { + return { entries: [], distributeGroup: null } + } + + // Distinct stepGroup namespace from delegator entries (which use + // `delegation:...`). Lets a wallet that is BOTH delegator and operator + // queue both legs without the cart's dedupe collapsing one into the other. + const stepGroup = `operator-commission:${splitContract.toLowerCase()}` + const claimGroupFor = (r: { rollupAddress: Address }) => + `${stepGroup}:${r.rollupAddress.toLowerCase()}` + + const entries: ClaimCartEntry[] = [] + + for (const r of claimables) { + const metadata: ClaimMetadata = { + stepType: ClaimStepType.SplitClaim, + stepGroupIdentifier: claimGroupFor(r), + splitContract, + rollupAddress: r.rollupAddress, + rollupVersion: r.rollupVersion, + amount: r.rewards, + } + entries.push({ + type: "claim", + label: `Claim — Rollup v${r.rollupVersion}`, + description: `${formatTokenAmountFull(r.rewards, decimals, symbol)} from ${providerLabel}`, + transaction: buildClaimSequencerRewardsTx(splitContract, r.rollupAddress), + metadata, + }) + } + + // Distribute requires the delegator-side recipient to rebuild splitData. + // When we don't have it (indexer hasn't surfaced an `atp.beneficiary` for + // this stake, typical for ERC20 wallet delegations), we queue the rollup + // claims only and bail on the distribute step. The caller's UI should + // explain this state so the operator knows why the chain isn't fully + // batched. + if (!delegatorBeneficiary) { + return { entries, distributeGroup: null } + } + + const totalAllocation = 10000n + const providerAllocation = BigInt(providerTakeRate) + const userAllocation = totalAllocation - providerAllocation + const splitData = { + recipients: [providerRewardsRecipient, delegatorBeneficiary], + allocations: [providerAllocation, userAllocation], + totalAllocation, + distributionIncentive: 0, + } + const distributeDependsOn: TransactionDependency[] = claimables.map((r) => ({ + stepType: ClaimStepType.SplitClaim, + stepGroupIdentifier: claimGroupFor(r), + })) + entries.push({ + type: "claim", + label: `Distribute — ${providerLabel}`, + description: `Push commission to warehouse`, + transaction: buildDistributeRewardsTx( + splitContract, + splitData, + tokenAddress, + // `distributor` parameter — pays the distribution-incentive bounty + // (currently 0). Setting it to the operator's recipient keeps any + // future incentive flowing to the same address as the commission. + providerRewardsRecipient, + ), + metadata: { + stepType: ClaimStepType.SplitDistribute, + stepGroupIdentifier: stepGroup, + splitContract, + tokenAddress, + dependsOn: distributeDependsOn.length ? distributeDependsOn : undefined, + }, + }) + + return { entries, distributeGroup: stepGroup } +} + +/** + * One warehouse withdraw that drains the operator's commission balance for + * `providerRewardsRecipient` + `tokenAddress`. Caller is expected to call + * this once per distinct recipient — see file header. + */ +export function buildOperatorWarehouseWithdrawEntry(inputs: { + warehouseAddress: Address + providerRewardsRecipient: Address + tokenAddress: Address + /** Every upstream distribute group this withdraw should wait on. */ + dependsOnDistributeGroups: string[] +}): ClaimCartEntry { + const { warehouseAddress, providerRewardsRecipient, tokenAddress, dependsOnDistributeGroups } = inputs + // The shared `buildWarehouseWithdrawEntry` only supports a single dep group + // (it's tailored to the delegator flow's "last distribute" chain). The + // operator path can have N parallel distributes feeding the same withdraw, + // so we build the entry inline with the full dep list. We still reuse the + // raw-tx helper from `claimCart.ts` to keep one source of truth for + // calldata encoding. + const base = buildWarehouseWithdrawEntry({ + warehouseAddress, + beneficiary: providerRewardsRecipient, + tokenAddress, + dependsOnDistributeGroup: null, + }) + return { + ...base, + label: "Withdraw commission", + description: `To ${providerRewardsRecipient.slice(0, 10)}…${providerRewardsRecipient.slice(-8)}`, + metadata: { + ...base.metadata, + stepGroupIdentifier: `operator-warehouse:${warehouseAddress.toLowerCase()}:${providerRewardsRecipient.toLowerCase()}`, + dependsOn: dependsOnDistributeGroups.map((g) => ({ + stepType: ClaimStepType.SplitDistribute, + stepGroupIdentifier: g, + })), + }, + } +} + +/** + * Convenience: collapse a fully-resolved set of operator splits into the + * ordered list of cart entries the cart's `addTransaction` expects. Caller + * passes the warehouse + token addresses (one warehouse per token), this + * function emits all per-split claims + distributes, then one withdraw per + * distinct `providerRewardsRecipient`. + */ +export function buildOperatorCommissionEntries(inputs: { + splits: OperatorSplitInputs[] + warehouseAddress: Address + tokenAddress: Address +}): ClaimCartEntry[] { + const { splits, warehouseAddress, tokenAddress } = inputs + const entries: ClaimCartEntry[] = [] + const depsByRecipient = new Map() + + for (const split of splits) { + const { entries: splitEntries, distributeGroup } = buildOperatorSplitEntries(split) + entries.push(...splitEntries) + if (distributeGroup) { + const key = split.providerRewardsRecipient.toLowerCase() + const list = depsByRecipient.get(key) ?? [] + list.push(distributeGroup) + depsByRecipient.set(key, list) + } + } + + // One withdraw per distinct recipient — the warehouse is per-(user, token). + for (const split of splits) { + const key = split.providerRewardsRecipient.toLowerCase() + const deps = depsByRecipient.get(key) + if (!deps || deps.length === 0) continue + // Mark consumed so multi-provider operators don't get N copies of the + // same withdraw. + depsByRecipient.delete(key) + entries.push( + buildOperatorWarehouseWithdrawEntry({ + warehouseAddress, + providerRewardsRecipient: split.providerRewardsRecipient, + tokenAddress, + dependsOnDistributeGroups: deps, + }), + ) + } + + return entries +} + +export type { ClaimCartEntry } from "@/utils/claimCart" +export type OperatorCommissionCartEntry = Omit, "id">