From da5aabfefe8189715ccf65c719dfe84bbe30bff1 Mon Sep 17 00:00:00 2001 From: Robert Brada Date: Wed, 11 Feb 2026 17:00:33 +0100 Subject: [PATCH] feat: additional atp support --- .gitignore | 1 + atp-indexer/.env.example | 9 +- atp-indexer/bootstrap.sh | 9 ++ atp-indexer/ponder.config.ts | 43 ++++++- atp-indexer/ponder.schema.ts | 2 + .../src/api/handlers/atp/beneficiary.ts | 1 + atp-indexer/src/api/types/atp.types.ts | 1 + atp-indexer/src/config/index.ts | 4 + .../src/events/atp-factory/atp-created.ts | 14 ++- .../ATPDetailsDelegationItem.tsx | 12 +- .../ATPDetailsDirectStakeItem.tsx | 9 +- .../ATPDetailsModal/ATPDetailsModal.tsx | 12 ++ .../ATPDetailsModal/WithdrawalActions.tsx | 64 +++++++++- .../ATPStakingCard/ATPStakingCard.tsx | 31 ++++- .../MilestoneStatusBadge.tsx | 54 +++++++++ .../components/MilestoneStatusBadge/index.ts | 1 + .../src/hooks/atp/atpBaseTypes.ts | 1 + .../src/hooks/atp/useAtpHoldings.ts | 1 + .../src/hooks/atp/useMultipleAtpData.ts | 7 +- .../src/hooks/atpRegistry/index.ts | 1 + .../hooks/atpRegistry/useMilestoneStatus.ts | 111 ++++++++++++++++++ .../src/hooks/staker/useInitiateWithdraw.ts | 47 +++++++- staking-dashboard/src/utils/factoryHelpers.ts | 39 ++++++ 23 files changed, 450 insertions(+), 24 deletions(-) create mode 100644 staking-dashboard/src/components/MilestoneStatusBadge/MilestoneStatusBadge.tsx create mode 100644 staking-dashboard/src/components/MilestoneStatusBadge/index.ts create mode 100644 staking-dashboard/src/hooks/atpRegistry/useMilestoneStatus.ts create mode 100644 staking-dashboard/src/utils/factoryHelpers.ts diff --git a/.gitignore b/.gitignore index 2f22bb934..ddcbb911d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ build/ .env.local .env.*.local .env.docker +.env.bak # IDE .idea/ diff --git a/atp-indexer/.env.example b/atp-indexer/.env.example index 7a77f2146..fe0ddd778 100644 --- a/atp-indexer/.env.example +++ b/atp-indexer/.env.example @@ -1,5 +1,5 @@ -# Database URL -DATABASE_URL=postgresql://user:password@localhost:5432/ponder +# Database (NOTE: Use POSTGRES_CONNECTION_STRING, not DATABASE_URL) +POSTGRES_CONNECTION_STRING=postgresql://user:password@localhost:5432/ponder # RPC URL RPC_URL=https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY @@ -10,11 +10,16 @@ CHAIN_ID=1 # Contract Addresses ATP_FACTORY_ADDRESS=0x... ATP_FACTORY_AUCTION_ADDRESS=0x... +ATP_FACTORY_EMPLOYEE_ADDRESS=0x... +ATP_FACTORY_INVESTOR_ADDRESS=0x... STAKING_REGISTRY_ADDRESS=0x... ROLLUP_ADDRESS=0x... # Indexer Settings START_BLOCK=0 +# Per-factory start blocks (optional, for efficiency - set to deployment block of each factory) +EMPLOYEE_FACTORY_START_BLOCK=0 +INVESTOR_FACTORY_START_BLOCK=0 # Application NODE_ENV=development diff --git a/atp-indexer/bootstrap.sh b/atp-indexer/bootstrap.sh index 9c73dcc6b..fe54afc7d 100755 --- a/atp-indexer/bootstrap.sh +++ b/atp-indexer/bootstrap.sh @@ -74,6 +74,10 @@ get_contract_addresses() { ATP_REGISTRY_AUCTION_ADDRESS=$(cat $contract_addresses_file | jq -r '.atpRegistryAuction') ATP_FACTORY_AUCTION_ADDRESS=$(cat $contract_addresses_file | jq -r '.atpFactoryAuction') + # new factories + ATP_FACTORY_EMPLOYEE_ADDRESS=$(cat $contract_addresses_file | jq -r '.atpFactoryEmployee') + ATP_FACTORY_INVESTOR_ADDRESS=$(cat $contract_addresses_file | jq -r '.atpFactoryInvestor') + # other STAKING_REGISTRY_ADDRESS=$(cat $contract_addresses_file | jq -r '.stakingRegistry') ROLLUP_ADDRESS=$(cat $contract_addresses_file | jq -r '.rollupAddress') @@ -150,6 +154,8 @@ CHAIN_ID=${CHAIN_ID} # Contract addresses ATP_FACTORY_ADDRESS=${ATP_FACTORY_ADDRESS} ATP_FACTORY_AUCTION_ADDRESS=${ATP_FACTORY_AUCTION_ADDRESS} +ATP_FACTORY_EMPLOYEE_ADDRESS=${ATP_FACTORY_EMPLOYEE_ADDRESS} +ATP_FACTORY_INVESTOR_ADDRESS=${ATP_FACTORY_INVESTOR_ADDRESS} STAKING_REGISTRY_ADDRESS=${STAKING_REGISTRY_ADDRESS} ROLLUP_ADDRESS=${ROLLUP_ADDRESS} @@ -187,6 +193,8 @@ CHAIN_ID=${CHAIN_ID} # Contract addresses ATP_FACTORY_ADDRESS=${ATP_FACTORY_ADDRESS} ATP_FACTORY_AUCTION_ADDRESS=${ATP_FACTORY_AUCTION_ADDRESS} +ATP_FACTORY_EMPLOYEE_ADDRESS=${ATP_FACTORY_EMPLOYEE_ADDRESS} +ATP_FACTORY_INVESTOR_ADDRESS=${ATP_FACTORY_INVESTOR_ADDRESS} STAKING_REGISTRY_ADDRESS=${STAKING_REGISTRY_ADDRESS} ROLLUP_ADDRESS=${ROLLUP_ADDRESS} @@ -470,6 +478,7 @@ case $ACTION in echo "" echo " Required contract address variables:" echo " ATP_FACTORY_ADDRESS, ATP_FACTORY_AUCTION_ADDRESS" + echo " ATP_FACTORY_EMPLOYEE_ADDRESS, ATP_FACTORY_INVESTOR_ADDRESS" echo " ATP_REGISTRY_ADDRESS, ATP_REGISTRY_AUCTION_ADDRESS" echo " STAKING_REGISTRY_ADDRESS, ROLLUP_ADDRESS" echo " START_BLOCK (optional, defaults to 0)" diff --git a/atp-indexer/ponder.config.ts b/atp-indexer/ponder.config.ts index 240b161be..f4a9c3569 100644 --- a/atp-indexer/ponder.config.ts +++ b/atp-indexer/ponder.config.ts @@ -13,6 +13,14 @@ const ATPCreatedEvent = parseAbiItem( "event ATPCreated(address indexed beneficiary, address indexed atp, uint256 allocation)" ); +// Per-factory start blocks for efficient indexing +const FACTORY_START_BLOCKS = { + genesis: config.START_BLOCK || 0, + auction: config.START_BLOCK || 0, + employee: config.EMPLOYEE_FACTORY_START_BLOCK || config.START_BLOCK || 0, + investor: config.INVESTOR_FACTORY_START_BLOCK || config.START_BLOCK || 0, +}; + let databaseConfig: DatabaseConfig | undefined; @@ -48,14 +56,14 @@ export default createConfig({ }, contracts: { /** - * ATP Factory - Main contract + * ATP Factory - Genesis Sale contract * Emits ATPCreated events when new ATP positions are created */ ATPFactory: { chain: config.networkName, abi: ATP_ABI, address: config.ATP_FACTORY_ADDRESS as `0x${string}`, - startBlock: config.START_BLOCK, + startBlock: FACTORY_START_BLOCKS.genesis, }, /** @@ -66,7 +74,29 @@ export default createConfig({ chain: config.networkName, abi: ATP_ABI, address: config.ATP_FACTORY_AUCTION_ADDRESS as `0x${string}`, - startBlock: config.START_BLOCK, + startBlock: FACTORY_START_BLOCKS.auction, + }, + + /** + * ATP Factory - Employee contract + * Issues MATPs to employees + */ + ATPFactoryEmployee: { + chain: config.networkName, + abi: ATP_ABI, + address: config.ATP_FACTORY_EMPLOYEE_ADDRESS as `0x${string}`, + startBlock: FACTORY_START_BLOCKS.employee, + }, + + /** + * ATP Factory - Investor contract + * Issues LATPs and MATPs to investors + */ + ATPFactoryInvestor: { + chain: config.networkName, + abi: ATP_ABI, + address: config.ATP_FACTORY_INVESTOR_ADDRESS as `0x${string}`, + startBlock: FACTORY_START_BLOCKS.investor, }, /** @@ -94,7 +124,7 @@ export default createConfig({ /** * Dynamic ATP Contracts * Created by factory events, tracks operator updates - * Uses factory pattern to only index ATPs created by our factories + * Uses factory pattern to only index ATPs created by all 4 factories */ ATP: { chain: config.networkName, @@ -103,11 +133,14 @@ export default createConfig({ address: [ config.ATP_FACTORY_ADDRESS as `0x${string}`, config.ATP_FACTORY_AUCTION_ADDRESS as `0x${string}`, + config.ATP_FACTORY_EMPLOYEE_ADDRESS as `0x${string}`, + config.ATP_FACTORY_INVESTOR_ADDRESS as `0x${string}`, ], event: ATPCreatedEvent, parameter: "atp", }), - startBlock: config.START_BLOCK, + // Use earliest factory start block to capture all ATP contracts + startBlock: Math.min(...Object.values(FACTORY_START_BLOCKS)), }, /** diff --git a/atp-indexer/ponder.schema.ts b/atp-indexer/ponder.schema.ts index 58170d559..d9200aee5 100644 --- a/atp-indexer/ponder.schema.ts +++ b/atp-indexer/ponder.schema.ts @@ -15,6 +15,7 @@ export const atpPosition = onchainTable("atp_position", (t) => ({ type: atpType("type").notNull(), stakerAddress: t.hex().notNull(), operatorAddress: t.hex(), + factoryAddress: t.hex().notNull(), // Factory that created this ATP blockNumber: t.bigint().notNull(), txHash: t.hex().notNull(), logIndex: t.integer().notNull(), @@ -23,6 +24,7 @@ export const atpPosition = onchainTable("atp_position", (t) => ({ addressIdx: index().on(table.address), beneficiaryIdx: index().on(table.beneficiary), stakerAddressIdx: index().on(table.stakerAddress), + factoryAddressIdx: index().on(table.factoryAddress), })); export const atpPositionRelations = relations(atpPosition, ({ many }) => ({ diff --git a/atp-indexer/src/api/handlers/atp/beneficiary.ts b/atp-indexer/src/api/handlers/atp/beneficiary.ts index 925bcc21e..07d2aabff 100644 --- a/atp-indexer/src/api/handlers/atp/beneficiary.ts +++ b/atp-indexer/src/api/handlers/atp/beneficiary.ts @@ -144,6 +144,7 @@ export async function handleATPByBeneficiary(c: Context): Promise { allocation: pos.allocation.toString(), type: pos.type, stakerAddress: checksumAddress(pos.stakerAddress), + factoryAddress: checksumAddress(pos.factoryAddress), sequentialNumber: index + 1, timestamp: Number(pos.timestamp), totalWithdrawn: (withdrawalMap.get(normalizedAddress) ?? 0n).toString(), diff --git a/atp-indexer/src/api/types/atp.types.ts b/atp-indexer/src/api/types/atp.types.ts index 033ac0d9b..816b5bd3f 100644 --- a/atp-indexer/src/api/types/atp.types.ts +++ b/atp-indexer/src/api/types/atp.types.ts @@ -56,6 +56,7 @@ export interface ATPPosition { allocation: string; type: string; stakerAddress: string; + factoryAddress: string; // Factory that created this ATP sequentialNumber: number; timestamp: number; totalWithdrawn?: string; diff --git a/atp-indexer/src/config/index.ts b/atp-indexer/src/config/index.ts index 4ec956f30..570aa4c99 100644 --- a/atp-indexer/src/config/index.ts +++ b/atp-indexer/src/config/index.ts @@ -48,11 +48,15 @@ const configSchema = z.object({ // Contract addresses ATP_FACTORY_ADDRESS: z.string().regex(/^0x[a-fA-F0-9]{40}$/, 'Invalid Ethereum address format'), ATP_FACTORY_AUCTION_ADDRESS: z.string().regex(/^0x[a-fA-F0-9]{40}$/, 'Invalid Ethereum address format'), + ATP_FACTORY_EMPLOYEE_ADDRESS: z.string().regex(/^0x[a-fA-F0-9]{40}$/, 'Invalid Ethereum address format'), + ATP_FACTORY_INVESTOR_ADDRESS: z.string().regex(/^0x[a-fA-F0-9]{40}$/, 'Invalid Ethereum address format'), STAKING_REGISTRY_ADDRESS: z.string().regex(/^0x[a-fA-F0-9]{40}$/, 'Invalid Ethereum address format'), ROLLUP_ADDRESS: z.string().regex(/^0x[a-fA-F0-9]{40}$/, 'Invalid Ethereum address format'), // Indexer settings START_BLOCK: z.string().transform(Number).refine(n => n >= 0, 'START_BLOCK must be non-negative').default('0'), + EMPLOYEE_FACTORY_START_BLOCK: z.string().transform(Number).refine(n => n >= 0, 'EMPLOYEE_FACTORY_START_BLOCK must be non-negative').optional(), + INVESTOR_FACTORY_START_BLOCK: z.string().transform(Number).refine(n => n >= 0, 'INVESTOR_FACTORY_START_BLOCK must be non-negative').optional(), // Application NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), diff --git a/atp-indexer/src/events/atp-factory/atp-created.ts b/atp-indexer/src/events/atp-factory/atp-created.ts index d2520bdaa..996a5025a 100644 --- a/atp-indexer/src/events/atp-factory/atp-created.ts +++ b/atp-indexer/src/events/atp-factory/atp-created.ts @@ -62,6 +62,7 @@ async function handleATPCreated({ event, context }: IndexingFunctionArgs<'ATPFac const atpType = await determineATPType(atp, client); const stakerAddress = await getStakerAddress(atp, client); + const factoryAddress = event.log.address; // Factory contract that emitted the event await db.insert(atpPosition).values({ id: normalizeAddress(atp), @@ -71,19 +72,28 @@ async function handleATPCreated({ event, context }: IndexingFunctionArgs<'ATPFac type: atpType, stakerAddress: normalizeAddress(stakerAddress) as `0x${string}`, operatorAddress: null, + factoryAddress: normalizeAddress(factoryAddress) as `0x${string}`, blockNumber: event.block.number, txHash: event.transaction.hash, logIndex: event.log.logIndex, timestamp: event.block.timestamp, }) - console.log(`${atpType} created (${source}): ${atp}`); + console.log(`${atpType} created (${source}): ${atp} from factory ${factoryAddress}`); } ponder.on("ATPFactory:ATPCreated", async (params) => { - await handleATPCreated(params, "factory"); + await handleATPCreated(params, "genesis"); }); ponder.on("ATPFactoryAuction:ATPCreated", async (params) => { await handleATPCreated(params, "auction"); }); + +ponder.on("ATPFactoryEmployee:ATPCreated", async (params) => { + await handleATPCreated(params, "employee"); +}); + +ponder.on("ATPFactoryInvestor:ATPCreated", async (params) => { + await handleATPCreated(params, "investor"); +}); diff --git a/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsDelegationItem.tsx b/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsDelegationItem.tsx index 9fe813304..64e5f9f02 100644 --- a/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsDelegationItem.tsx +++ b/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsDelegationItem.tsx @@ -32,6 +32,10 @@ interface ATPDetailsDelegationItemProps { providerRewardsRecipient: string }) => void onWithdrawSuccess?: () => void + // ATP context for milestone validation + atpType?: string + registryAddress?: Address + milestoneId?: bigint } /** @@ -45,7 +49,10 @@ export const ATPDetailsDelegationItem = ({ stakerAddress, rollupVersion, onClaimClick, - onWithdrawSuccess + onWithdrawSuccess, + atpType, + registryAddress, + milestoneId }: ATPDetailsDelegationItemProps) => { const [isExpanded, setIsExpanded] = useState(false) const { symbol, decimals } = useStakingAssetTokenDetails() @@ -488,6 +495,9 @@ export const ATPDetailsDelegationItem = ({ refetchStatus() onWithdrawSuccess?.() }} + atpType={atpType} + registryAddress={registryAddress} + milestoneId={milestoneId} /> )} diff --git a/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsDirectStakeItem.tsx b/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsDirectStakeItem.tsx index d2dc10dfb..fa0ce6534 100644 --- a/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsDirectStakeItem.tsx +++ b/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsDirectStakeItem.tsx @@ -26,13 +26,17 @@ interface ATPDetailsDirectStakeItemProps { atp: ATPData onClaimSuccess?: () => void onWithdrawSuccess?: () => void + // ATP context for milestone validation + atpType?: string + registryAddress?: Address + milestoneId?: bigint } /** * Individual self stake item component * Displays sequencer address, transaction info, and links to explorers */ -export const ATPDetailsDirectStakeItem = ({ stake, stakerAddress, rollupVersion, atp, onClaimSuccess, onWithdrawSuccess }: ATPDetailsDirectStakeItemProps) => { +export const ATPDetailsDirectStakeItem = ({ stake, stakerAddress, rollupVersion, atp, onClaimSuccess, onWithdrawSuccess, atpType, registryAddress, milestoneId }: ATPDetailsDirectStakeItemProps) => { const [isExpanded, setIsExpanded] = useState(false) const [isClaimModalOpen, setIsClaimModalOpen] = useState(false) const { symbol, decimals } = useStakingAssetTokenDetails() @@ -393,6 +397,9 @@ export const ATPDetailsDirectStakeItem = ({ stake, stakerAddress, rollupVersion, refetchStatus() onWithdrawSuccess?.() }} + atpType={atpType} + registryAddress={registryAddress} + milestoneId={milestoneId} /> )} diff --git a/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsModal.tsx b/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsModal.tsx index 421645a1d..e4d2d1cda 100644 --- a/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsModal.tsx +++ b/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsModal.tsx @@ -22,6 +22,7 @@ import { ClaimAllProvider } from "@/contexts/ClaimAllContext" import { ClaimAllDelegationRewardsButton } from "@/components/ClaimAllDelegationRewardsButton" import { ClaimDelegationRewardsModal, type DelegationModalData } from "@/components/ClaimDelegationRewardsModal" import type { ATPData } from "@/hooks/atp" +import { isMATPData } from "@/hooks/atp/matp/matpTypes" import type { Address } from "viem" interface ATPDetailsModalProps { @@ -254,6 +255,11 @@ export const ATPDetailsModal = ({ atp, isOpen, onClose, onWithdrawSuccess, onRef const { messages: alertMessages, type: alertType } = alertData + // Extract ATP context for milestone validation + const atpType = atp.typeString; // "MATP", "LATP", "NCATP" + const registryAddress = atp.registry as Address; + const milestoneId = isMATPData(atp) ? atp.milestoneId : undefined; + return createPortal(
))}
@@ -448,6 +457,9 @@ export const ATPDetailsModal = ({ atp, isOpen, onClose, onWithdrawSuccess, onRef rollupVersion={rollupVersion} onClaimClick={handleDelegationClaimClick} onWithdrawSuccess={handleWithdrawSuccess} + atpType={atpType} + registryAddress={registryAddress} + milestoneId={milestoneId} /> ))} diff --git a/staking-dashboard/src/components/ATPDetailsModal/WithdrawalActions.tsx b/staking-dashboard/src/components/ATPDetailsModal/WithdrawalActions.tsx index 1df37bc5c..51e1a2b9f 100644 --- a/staking-dashboard/src/components/ATPDetailsModal/WithdrawalActions.tsx +++ b/staking-dashboard/src/components/ATPDetailsModal/WithdrawalActions.tsx @@ -6,6 +6,7 @@ import { TooltipIcon } from "@/components/Tooltip"; import { SequencerStatus } from "@/hooks/rollup/useSequencerStatus"; import { useAlert } from "@/contexts/AlertContext"; import { getUnlockTimeDisplay } from "@/utils/dateFormatters"; +import { MilestoneStatusBadge } from "@/components/MilestoneStatusBadge"; /** * Parse contract errors to extract user-friendly messages @@ -79,6 +80,10 @@ interface WithdrawalActionsProps { actualUnlockTime?: bigint; withdrawalDelayDays?: number; onSuccess?: () => void; + // ATP context for milestone validation + atpType?: string; + registryAddress?: Address; + milestoneId?: bigint; } /** @@ -94,6 +99,10 @@ export const WithdrawalActions = ({ actualUnlockTime, withdrawalDelayDays, onSuccess, + // ATP context + atpType, + registryAddress, + milestoneId, }: WithdrawalActionsProps) => { const { showAlert } = useAlert(); const isExiting = status === SequencerStatus.EXITING; @@ -104,7 +113,15 @@ export const WithdrawalActions = ({ isConfirming: isConfirmingInitiate, isSuccess: isInitiateSuccess, error: initiateError, - } = useInitiateWithdraw(stakerAddress); + milestoneStatus, + isMilestoneLoading, + canWithdraw, + milestoneBlockError, + } = useInitiateWithdraw(stakerAddress, { + registryAddress, + milestoneId, + atpType, + }); const { finalizeWithdraw, @@ -114,8 +131,17 @@ export const WithdrawalActions = ({ error: finalizeError, } = useFinalizeWithdraw(); + // Determine if milestone gates operations + const isMATP = atpType === 'MATP'; + const isMilestoneGated = isMATP && !canWithdraw; + const canInitiateUnstake = - status === SequencerStatus.VALIDATING || status === SequencerStatus.ZOMBIE; + (status === SequencerStatus.VALIDATING || status === SequencerStatus.ZOMBIE) + && !isMilestoneGated; // Block if milestone not succeeded + + const canFinalizeWithdrawNow = + canFinalize + && !isMilestoneGated; // Block if milestone not succeeded // Handle initiate withdraw errors useEffect(() => { @@ -178,6 +204,25 @@ export const WithdrawalActions = ({ maxWidth="max-w-md" /> + + {/* Show milestone status for MATPs */} + {isMATP && ( +
+ +
+ )} + + {/* Show milestone error message */} + {milestoneBlockError && ( +
+
+ {milestoneBlockError} +
+
+ )}
)}
diff --git a/staking-dashboard/src/components/MilestoneStatusBadge/MilestoneStatusBadge.tsx b/staking-dashboard/src/components/MilestoneStatusBadge/MilestoneStatusBadge.tsx new file mode 100644 index 000000000..ccb99618c --- /dev/null +++ b/staking-dashboard/src/components/MilestoneStatusBadge/MilestoneStatusBadge.tsx @@ -0,0 +1,54 @@ +import { Tooltip } from "@/components/Tooltip"; +import { + MilestoneStatus, + getMilestoneStatusText, + getMilestoneStatusColors, +} from "@/hooks/atpRegistry/useMilestoneStatus"; + +interface MilestoneStatusBadgeProps { + status?: MilestoneStatus; + isLoading?: boolean; + showTooltip?: boolean; +} + +export const MilestoneStatusBadge = ({ + status, + isLoading, + showTooltip = true +}: MilestoneStatusBadgeProps) => { + if (isLoading) { + return ( + + Loading... + + ); + } + + if (status === undefined) return null; + + const statusText = getMilestoneStatusText(status); + const colors = getMilestoneStatusColors(status); + + const tooltipContent = { + [MilestoneStatus.Pending]: + "This milestone has not been reached yet. Withdrawals are disabled.", + [MilestoneStatus.Failed]: + "This milestone was not achieved. Control has transferred to Aztec Labs.", + [MilestoneStatus.Succeeded]: + "This milestone has been successfully achieved. All operations are available.", + }[status]; + + const badge = ( + + + Milestone: {statusText} + + ); + + if (!showTooltip) return badge; + + return {badge}; +}; diff --git a/staking-dashboard/src/components/MilestoneStatusBadge/index.ts b/staking-dashboard/src/components/MilestoneStatusBadge/index.ts new file mode 100644 index 000000000..8f6399684 --- /dev/null +++ b/staking-dashboard/src/components/MilestoneStatusBadge/index.ts @@ -0,0 +1 @@ +export { MilestoneStatusBadge } from "./MilestoneStatusBadge"; diff --git a/staking-dashboard/src/hooks/atp/atpBaseTypes.ts b/staking-dashboard/src/hooks/atp/atpBaseTypes.ts index 8939a1532..7a4b58a64 100644 --- a/staking-dashboard/src/hooks/atp/atpBaseTypes.ts +++ b/staking-dashboard/src/hooks/atp/atpBaseTypes.ts @@ -36,6 +36,7 @@ export const BaseATPSchema = z.object({ sequentialNumber: z.number().optional(), totalWithdrawn: z.bigint().optional(), totalSlashed: z.bigint().optional(), + factoryAddress: AddressSchema.optional(), // Factory that created this ATP }); // Base ATP data type diff --git a/staking-dashboard/src/hooks/atp/useAtpHoldings.ts b/staking-dashboard/src/hooks/atp/useAtpHoldings.ts index e5e651ef7..0a1746924 100644 --- a/staking-dashboard/src/hooks/atp/useAtpHoldings.ts +++ b/staking-dashboard/src/hooks/atp/useAtpHoldings.ts @@ -9,6 +9,7 @@ export interface ATPHolding { allocation: string; beneficiary: string; stakerAddress: string; + factoryAddress: string; // Factory that created this ATP sequentialNumber: number; timestamp: number; totalWithdrawn: string; diff --git a/staking-dashboard/src/hooks/atp/useMultipleAtpData.ts b/staking-dashboard/src/hooks/atp/useMultipleAtpData.ts index 1454349db..e3f4e8f33 100644 --- a/staking-dashboard/src/hooks/atp/useMultipleAtpData.ts +++ b/staking-dashboard/src/hooks/atp/useMultipleAtpData.ts @@ -62,14 +62,14 @@ function buildAtpData( if (holding.type === 'MATP') { const data = buildMATPData(address, results); - return { ...data, sequentialNumber: holding.sequentialNumber, totalWithdrawn, totalSlashed }; + return { ...data, sequentialNumber: holding.sequentialNumber, totalWithdrawn, totalSlashed, factoryAddress: holding.factoryAddress as `0x${string}` }; } if (holding.type === 'LATP') { // Not overriding the global lock because the ATPRegistryAuction global lock params already returns the correct timestamp // https://etherscan.io/address/0x63841bAD6B35b6419e15cA9bBBbDf446D4dC3dde#readContract const data = buildLATPData(address, results); - return { ...data, sequentialNumber: holding.sequentialNumber, totalWithdrawn, totalSlashed }; + return { ...data, sequentialNumber: holding.sequentialNumber, totalWithdrawn, totalSlashed, factoryAddress: holding.factoryAddress as `0x${string}` }; } if (holding.type === 'NCATP') { @@ -82,7 +82,7 @@ function buildAtpData( withdrawalTimestamp: overrides?.withdrawalTimestamp, hasStaked: overrides?.hasStaked }); - return { ...data, sequentialNumber: holding.sequentialNumber, totalWithdrawn, totalSlashed }; + return { ...data, sequentialNumber: holding.sequentialNumber, totalWithdrawn, totalSlashed, factoryAddress: holding.factoryAddress as `0x${string}` }; } // Unknown type fallback @@ -103,6 +103,7 @@ function buildAtpData( sequentialNumber: holding.sequentialNumber, totalWithdrawn, totalSlashed, + factoryAddress: holding.factoryAddress as `0x${string}`, } as ATPData; } diff --git a/staking-dashboard/src/hooks/atpRegistry/index.ts b/staking-dashboard/src/hooks/atpRegistry/index.ts index 93c372bbd..130c601c7 100644 --- a/staking-dashboard/src/hooks/atpRegistry/index.ts +++ b/staking-dashboard/src/hooks/atpRegistry/index.ts @@ -4,6 +4,7 @@ export { useAtpRegistryData, isAuctionRegistry } from "./useAtpRegistryData"; // Parameterized read hooks export { useStakerImplementation } from "./useStakerImplementation"; export { useStakerImplementations } from "./useStakerImplementations"; +export * from "./useMilestoneStatus"; // Write hook - keep separate for transactions export { useSetExecuteAllowedAt } from "./useSetExecuteAllowedAt"; diff --git a/staking-dashboard/src/hooks/atpRegistry/useMilestoneStatus.ts b/staking-dashboard/src/hooks/atpRegistry/useMilestoneStatus.ts new file mode 100644 index 000000000..780971b31 --- /dev/null +++ b/staking-dashboard/src/hooks/atpRegistry/useMilestoneStatus.ts @@ -0,0 +1,111 @@ +import { useReadContract } from "wagmi"; +import type { Address } from "viem"; +import { AtpRegistryAbi } from "../../contracts/abis/ATPRegistry"; + +/** + * Milestone status enum matching Registry.sol + * CRITICAL: Use "Succeeded" (value 2), NOT "Reached" + */ +export enum MilestoneStatus { + Pending = 0, // Milestone not yet reached + Failed = 1, // Milestone failed + Succeeded = 2, // Milestone achieved - ONLY this allows operations +} + +interface UseMilestoneStatusParams { + registryAddress: Address | undefined; + milestoneId?: bigint; + enabled?: boolean; +} + +/** + * Hook to fetch milestone status from ATP Registry + */ +export function useMilestoneStatus({ + registryAddress, + milestoneId, + enabled = true, +}: UseMilestoneStatusParams) { + const milestoneStatusQuery = useReadContract({ + abi: AtpRegistryAbi, + address: registryAddress, + functionName: "getMilestoneStatus", + args: milestoneId !== undefined ? [milestoneId] : undefined, + query: { + enabled: enabled && registryAddress !== undefined && milestoneId !== undefined, + refetchInterval: 5 * 60 * 1000, // Refresh every 5 minutes + staleTime: 5 * 60 * 1000, // Cache for 5 minutes + }, + }); + + return { + status: milestoneStatusQuery.data as MilestoneStatus | undefined, + isLoading: milestoneStatusQuery.isLoading, + error: milestoneStatusQuery.error, + refetch: milestoneStatusQuery.refetch, + }; +} + +/** + * Helper to check if milestone allows withdrawal + */ +export function canWithdrawWithMilestone(status?: MilestoneStatus): boolean { + return status === MilestoneStatus.Succeeded; +} + +/** + * Helper to get human-readable status text + */ +export function getMilestoneStatusText(status?: MilestoneStatus): string { + switch (status) { + case MilestoneStatus.Pending: + return "Pending"; + case MilestoneStatus.Failed: + return "Failed"; + case MilestoneStatus.Succeeded: + return "Achieved"; // User-friendly text + default: + return "Unknown"; + } +} + +/** + * Helper to get status colors (using actual theme colors) + */ +export function getMilestoneStatusColors(status?: MilestoneStatus): { + text: string; + bg: string; + border: string; + indicator: string; +} { + switch (status) { + case MilestoneStatus.Pending: + return { + text: "text-aqua", + bg: "bg-aqua/10", + border: "border-aqua/40", + indicator: "bg-aqua", + }; + case MilestoneStatus.Failed: + return { + text: "text-vermillion", + bg: "bg-vermillion/10", + border: "border-vermillion/40", + indicator: "bg-vermillion", + }; + case MilestoneStatus.Succeeded: + return { + text: "text-chartreuse", + bg: "bg-chartreuse/10", + border: "border-chartreuse/40", + indicator: "bg-chartreuse", + }; + default: + return { + text: "text-parchment/60", + bg: "bg-parchment/5", + border: "border-parchment/20", + indicator: "bg-parchment/40", + }; + } +} diff --git a/staking-dashboard/src/hooks/staker/useInitiateWithdraw.ts b/staking-dashboard/src/hooks/staker/useInitiateWithdraw.ts index e0332aee2..3af3bc5c0 100644 --- a/staking-dashboard/src/hooks/staker/useInitiateWithdraw.ts +++ b/staking-dashboard/src/hooks/staker/useInitiateWithdraw.ts @@ -1,20 +1,56 @@ import { useWriteContract, useWaitForTransactionReceipt } from "@/hooks/useWagmiStrategy" import type { Address } from "viem" import { ATPWithdrawableStakerAbi } from "@/contracts/abis/ATPWithdrawableStaker" +import { + useMilestoneStatus, + canWithdrawWithMilestone, + getMilestoneStatusText, +} from "@/hooks/atpRegistry/useMilestoneStatus" + +interface UseInitiateWithdrawOptions { + registryAddress?: Address; + milestoneId?: bigint; + atpType?: string; +} /** * Hook to initiate withdrawal from the rollup for a delegation * @param stakerAddress - Address of the withdrawable staker contract + * @param options - Optional milestone validation parameters * @returns Hook with initiateWithdraw function and transaction status */ -export function useInitiateWithdraw(stakerAddress: Address) { - const { data: hash, writeContract, isPending, error } = useWriteContract() +export function useInitiateWithdraw( + stakerAddress: Address, + options?: UseInitiateWithdrawOptions +) { + const { data: hash, writeContract, isPending, error: writeError } = useWriteContract() const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash, }) + // Check milestone status for MATPs + const { + status: milestoneStatus, + isLoading: isMilestoneLoading, + error: milestoneError, + } = useMilestoneStatus({ + registryAddress: options?.registryAddress, + milestoneId: options?.milestoneId, + enabled: options?.atpType === 'MATP' && !!options?.registryAddress, + }); + + const isMATP = options?.atpType === 'MATP'; + const canWithdraw = !isMATP || canWithdrawWithMilestone(milestoneStatus); + + // Build error message if milestone blocks withdrawal + const milestoneBlockError = isMATP && !canWithdraw + ? `Cannot withdraw: milestone status is ${getMilestoneStatusText(milestoneStatus)}. ` + + `Withdrawals require milestone to be achieved (Succeeded status).` + : null; + const initiateWithdraw = (version: bigint, attesterAddress: Address) => { + // Don't throw - let UI handle via disabled state return writeContract({ abi: ATPWithdrawableStakerAbi, address: stakerAddress, @@ -28,7 +64,12 @@ export function useInitiateWithdraw(stakerAddress: Address) { isPending, isConfirming, isSuccess, - error, + error: writeError || milestoneError, hash, + // Milestone-specific state for UI + milestoneStatus, + isMilestoneLoading, + canWithdraw, + milestoneBlockError, // Explicit error message for UI } } diff --git a/staking-dashboard/src/utils/factoryHelpers.ts b/staking-dashboard/src/utils/factoryHelpers.ts new file mode 100644 index 000000000..aea9648c8 --- /dev/null +++ b/staking-dashboard/src/utils/factoryHelpers.ts @@ -0,0 +1,39 @@ +import type { Address } from "viem"; + +/** + * Map of factory addresses to human-readable names + * These are network-specific addresses + */ +export const FACTORY_NAMES: Record = { + // Mainnet factories + "0xaa292e8611adf267e563f334ee42320ac96d0463": "Genesis Sale", + "0x3155755b79aa083bd953911c92705b7aa82a18f9": "Auction", + "0xa17ea96757c9bb9b41a12ef5073c51129937ffae": "Employee", + "0x278f39b11b3de0796561e85cb48535c9f45ddfcc": "Investor", + + // Anvil/Dev factories + "0xd6e1afe5ca8d00a2efc01b89997abe2de47fdfaf": "Employee", + "0x6f6f570f45833e249e27022648a26f4076f48f78": "Investor", +}; + +/** + * Get human-readable factory name from factory address + * Falls back to "Unknown" if factory address is not recognized + */ +export function getFactoryName(factoryAddress?: Address | string): string { + if (!factoryAddress) { + return "Unknown"; + } + + const normalized = factoryAddress.toLowerCase(); + return FACTORY_NAMES[normalized] || "Unknown"; +} + +/** + * Get short factory identifier (first word only) + * Used for compact displays + */ +export function getFactoryShortName(factoryAddress?: Address | string): string { + const fullName = getFactoryName(factoryAddress); + return fullName.split(" ")[0]; // Returns "Genesis", "Auction", "Employee", "Investor", or "Unknown" +}