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
31 changes: 25 additions & 6 deletions atp-indexer/src/api/handlers/provider/details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import {
erc20StakedWithProvider,
providerTakeRateUpdate,
staked,
failedDeposit
failedDeposit,
atpPosition
} from 'ponder:schema';

/**
Expand Down Expand Up @@ -42,9 +43,18 @@ export async function handleProviderDetails(c: Context): Promise<Response> {

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)
Expand All @@ -55,10 +65,15 @@ export async function handleProviderDetails(c: Context): Promise<Response> {
.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
Expand Down Expand Up @@ -136,6 +151,10 @@ export async function handleProviderDetails(c: Context): Promise<Response> {
// 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),
Expand Down
8 changes: 8 additions & 0 deletions atp-indexer/src/api/types/provider.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,15 @@ export const ATPStakingOverviewClaimableRewards = forwardRef<HTMLDivElement, ATP

{isExpanded && (
<div className="absolute top-full left-0 right-0 mt-2 bg-ink border border-parchment/20 p-4 z-10 shadow-lg">
{/* 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. */}
<div>
<div className="text-xs text-parchment/60 uppercase tracking-wide mb-1 font-oracle-standard">Delegation Rewards</div>
<div className="font-mono text-base font-bold text-parchment">
Expand All @@ -72,6 +80,40 @@ export const ATPStakingOverviewClaimableRewards = forwardRef<HTMLDivElement, ATP
<div className="text-xs text-parchment/50 mt-1">
Earned from staking through providers
</div>
{totalRewards > 0n && (
<div className="mt-3 grid grid-cols-2 gap-3 pt-3 border-t border-parchment/10">
<div>
<div className="flex items-center gap-1">
<span className="text-[10px] text-parchment/50 uppercase tracking-wide font-oracle-standard">
Pending distribute
</span>
<TooltipIcon
content="Your share that still needs the split contract's distribute step to be called. Counts both unclaimed rollup rewards and tokens already pulled to the split contract."
size="sm"
maxWidth="max-w-xs"
/>
</div>
<div className="font-mono text-sm font-bold text-parchment">
{formatTokenAmount(totalRewards - pendingWarehouseWithdrawal, decimals, symbol)}
</div>
</div>
<div>
<div className="flex items-center gap-1">
<span className="text-[10px] text-parchment/50 uppercase tracking-wide font-oracle-standard">
Already in warehouse
</span>
<TooltipIcon
content="Tokens already distributed to your address in the SplitsWarehouse. A single withdraw call sweeps the entire balance to your wallet."
size="sm"
maxWidth="max-w-xs"
/>
</div>
<div className="font-mono text-sm font-bold text-parchment">
{formatTokenAmount(pendingWarehouseWithdrawal, decimals, symbol)}
</div>
</div>
</div>
)}
</div>

{/* Self-Stake Rewards Section */}
Expand Down
25 changes: 25 additions & 0 deletions staking-dashboard/src/components/MainContent/MainContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
}

Expand Down Expand Up @@ -132,6 +141,22 @@ export const MainContent = () => {
}`}></div>
{applyHeroItalics("Stake")}
</Link>
{isOperator && (
<Link
to="/operator"
className={`relative px-4 sm:px-6 md:px-8 py-3 sm:py-4 font-arizona-text text-base sm:text-xl md:text-2xl font-light transition-colors whitespace-nowrap min-h-[48px] sm:min-h-[56px] flex items-center ${activeTab === 'operator'
? 'text-parchment'
: 'text-chartreuse/80 hover:text-chartreuse'
}`}
>
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-px h-1/2 bg-parchment/20"></div>
<div className={`absolute bottom-0 left-1/2 -translate-x-1/2 h-0.5 w-2/3 transition-colors ${activeTab === 'operator'
? 'bg-chartreuse'
: 'bg-transparent group-hover:bg-parchment/30'
}`}></div>
{applyHeroItalics("Operator Tools")}
</Link>
)}
</div>
</div>

Expand Down
3 changes: 3 additions & 0 deletions staking-dashboard/src/hooks/operator/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./useConnectedOperatorIdentities"
export * from "./useOperatorSplitContracts"
export * from "./useOperatorOnChainReads"
Original file line number Diff line number Diff line change
@@ -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<void>
}

interface ApiProviderListItem {
id: string
address?: string
}

interface ApiProvidersResponse {
providers?: ApiProviderListItem[]
}

async function fetchAllProviders(): Promise<ApiProviderListItem[]> {
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<number[]>(() => {
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<void>` 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<number, OperatorIdentity>()
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])
}
Loading
Loading