diff --git a/.github/workflows/deploy-indexer.yaml b/.github/workflows/deploy-indexer.yaml index 0ab6d1dac..783e03f18 100644 --- a/.github/workflows/deploy-indexer.yaml +++ b/.github/workflows/deploy-indexer.yaml @@ -56,6 +56,8 @@ jobs: # Contract Addresses (from GitHub environment variables) ATP_FACTORY_ADDRESS: ${{ vars.ATP_FACTORY_ADDRESS }} ATP_FACTORY_AUCTION_ADDRESS: ${{ vars.ATP_FACTORY_AUCTION_ADDRESS }} + ATP_FACTORY_MATP_ADDRESS: ${{ vars.ATP_FACTORY_MATP_ADDRESS }} + ATP_FACTORY_LATP_ADDRESS: ${{ vars.ATP_FACTORY_LATP_ADDRESS }} ATP_REGISTRY_ADDRESS: ${{ vars.ATP_REGISTRY_ADDRESS }} ATP_REGISTRY_AUCTION_ADDRESS: ${{ vars.ATP_REGISTRY_AUCTION_ADDRESS }} STAKING_REGISTRY_ADDRESS: ${{ vars.STAKING_REGISTRY_ADDRESS }} 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..54b65d1d7 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_MATP_ADDRESS=0x... +ATP_FACTORY_LATP_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) +MATP_FACTORY_START_BLOCK=0 +LATP_FACTORY_START_BLOCK=0 # Application NODE_ENV=development diff --git a/atp-indexer/bootstrap.sh b/atp-indexer/bootstrap.sh index 4eeec27b2..a2e26f3ec 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_MATP_ADDRESS=$(cat $contract_addresses_file | jq -r '.atpFactoryMatp') + ATP_FACTORY_LATP_ADDRESS=$(cat $contract_addresses_file | jq -r '.atpFactoryLatp') + # 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_MATP_ADDRESS=${ATP_FACTORY_MATP_ADDRESS} +ATP_FACTORY_LATP_ADDRESS=${ATP_FACTORY_LATP_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_MATP_ADDRESS=${ATP_FACTORY_MATP_ADDRESS} +ATP_FACTORY_LATP_ADDRESS=${ATP_FACTORY_LATP_ADDRESS} STAKING_REGISTRY_ADDRESS=${STAKING_REGISTRY_ADDRESS} ROLLUP_ADDRESS=${ROLLUP_ADDRESS} @@ -361,6 +369,8 @@ function deploy() { local args="-var=rpc_url=$RPC_URL \ -var=atp_factory_address=$ATP_FACTORY_ADDRESS \ -var=atp_factory_auction_address=$ATP_FACTORY_AUCTION_ADDRESS \ + -var=atp_factory_matp_address=$ATP_FACTORY_MATP_ADDRESS \ + -var=atp_factory_latp_address=$ATP_FACTORY_LATP_ADDRESS \ -var=staking_registry_address=$STAKING_REGISTRY_ADDRESS \ -var=rollup_address=$ROLLUP_ADDRESS \ -var=start_block=$START_BLOCK \ @@ -469,6 +479,7 @@ case $ACTION in echo "" echo " Required contract address variables:" echo " ATP_FACTORY_ADDRESS, ATP_FACTORY_AUCTION_ADDRESS" + echo " ATP_FACTORY_MATP_ADDRESS, ATP_FACTORY_LATP_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..83326702b 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, + matp: config.MATP_FACTORY_START_BLOCK || config.START_BLOCK || 0, + latp: config.LATP_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 - MATP contract + * Issues milestone-based ATPs (MATPs) + */ + ATPFactoryMATP: { + chain: config.networkName, + abi: ATP_ABI, + address: config.ATP_FACTORY_MATP_ADDRESS as `0x${string}`, + startBlock: FACTORY_START_BLOCKS.matp, + }, + + /** + * ATP Factory - LATP contract + * Issues linear vesting ATPs (LATPs) and MATPs + */ + ATPFactoryLATP: { + chain: config.networkName, + abi: ATP_ABI, + address: config.ATP_FACTORY_LATP_ADDRESS as `0x${string}`, + startBlock: FACTORY_START_BLOCKS.latp, }, /** @@ -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_MATP_ADDRESS as `0x${string}`, + config.ATP_FACTORY_LATP_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..97111f554 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_MATP_ADDRESS: z.string().regex(/^0x[a-fA-F0-9]{40}$/, 'Invalid Ethereum address format'), + ATP_FACTORY_LATP_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'), + MATP_FACTORY_START_BLOCK: z.string().transform(Number).refine(n => n >= 0, 'MATP_FACTORY_START_BLOCK must be non-negative').optional(), + LATP_FACTORY_START_BLOCK: z.string().transform(Number).refine(n => n >= 0, 'LATP_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..c08a52678 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("ATPFactoryMATP:ATPCreated", async (params) => { + await handleATPCreated(params, "matp"); +}); + +ponder.on("ATPFactoryLATP:ATPCreated", async (params) => { + await handleATPCreated(params, "latp"); +}); diff --git a/atp-indexer/terraform/app.tf b/atp-indexer/terraform/app.tf index 54f6d13bf..e92ecbf0c 100644 --- a/atp-indexer/terraform/app.tf +++ b/atp-indexer/terraform/app.tf @@ -257,6 +257,8 @@ locals { { name = "ATP_FACTORY_ADDRESS", value = var.atp_factory_address }, { name = "STAKING_REGISTRY_ADDRESS", value = var.staking_registry_address }, { name = "ATP_FACTORY_AUCTION_ADDRESS", value = var.atp_factory_auction_address }, + { name = "ATP_FACTORY_MATP_ADDRESS", value = var.atp_factory_matp_address }, + { name = "ATP_FACTORY_LATP_ADDRESS", value = var.atp_factory_latp_address }, { name = "ROLLUP_ADDRESS", value = var.rollup_address }, { name = "POLLING_INTERVAL", value = var.polling_interval }, { name = "MAX_RETRIES", value = var.max_retries }, @@ -282,6 +284,8 @@ locals { { name = "ATP_FACTORY_ADDRESS", value = var.atp_factory_address }, { name = "STAKING_REGISTRY_ADDRESS", value = var.staking_registry_address }, { name = "ATP_FACTORY_AUCTION_ADDRESS", value = var.atp_factory_auction_address }, + { name = "ATP_FACTORY_MATP_ADDRESS", value = var.atp_factory_matp_address }, + { name = "ATP_FACTORY_LATP_ADDRESS", value = var.atp_factory_latp_address }, { name = "ROLLUP_ADDRESS", value = var.rollup_address }, { name = "POLLING_INTERVAL", value = var.polling_interval }, { name = "MAX_RETRIES", value = var.max_retries }, diff --git a/atp-indexer/terraform/variables.tf b/atp-indexer/terraform/variables.tf index 04a91488d..1a7b5fb8f 100644 --- a/atp-indexer/terraform/variables.tf +++ b/atp-indexer/terraform/variables.tf @@ -127,6 +127,18 @@ variable "atp_factory_auction_address" { default = "" } +variable "atp_factory_matp_address" { + description = "ATP Factory MATP contract address" + type = string + default = "" +} + +variable "atp_factory_latp_address" { + description = "ATP Factory LATP contract address" + type = string + default = "" +} + variable "staking_registry_address" { description = "Staking Registry contract address" type = string diff --git a/db-schemas.json b/db-schemas.json index 30bfcbc66..8e02edf95 100644 --- a/db-schemas.json +++ b/db-schemas.json @@ -1,6 +1,6 @@ { "atp-indexer": { "testnet": "atp-indexer-testnet-v03", - "prod": "atp-indexer-prod-v15" + "prod": "atp-indexer-prod-v16" } } diff --git a/staking-dashboard/src/components/ATPCard/ATPCard.tsx b/staking-dashboard/src/components/ATPCard/ATPCard.tsx index 9ddef17f7..9b6eafdef 100644 --- a/staking-dashboard/src/components/ATPCard/ATPCard.tsx +++ b/staking-dashboard/src/components/ATPCard/ATPCard.tsx @@ -152,7 +152,7 @@ export default function ATPCard({
{atp.milestoneId !== undefined && ( - Milestone {atp.milestoneId.toString()} + Milestone {Number(atp.milestoneId) + 1} )} 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 71972c3e7..4f8d81426 100644 --- a/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsModal.tsx +++ b/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsModal.tsx @@ -7,6 +7,7 @@ import { useRollupData } from "@/hooks/rollup/useRollupData" import { useStakingAssetTokenDetails } from "@/hooks/stakingRegistry" import { useATP } from "@/hooks/useATP" import { useUserGovernancePower, usePendingWithdrawals } from "@/hooks/governance" +import { useBlockTimestamp } from "@/hooks/useBlockTimestamp" import { formatTokenAmount } from "@/utils/atpFormatters" import { Icon } from "@/components/Icon" import { ATPDetailsHeader } from "./ATPDetailsHeader" @@ -22,6 +23,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 { @@ -124,6 +126,7 @@ export const ATPDetailsModal = ({ atp, isOpen, onClose, onWithdrawSuccess, onRef const { votingPower: governanceVotingPower, refetch: refetchGovernancePower } = useUserGovernancePower({ stakerAddress: atp.staker }) + const { blockTimestamp } = useBlockTimestamp() // Get pending governance withdrawals for this ATP (initiated but not yet finalized) const { pendingWithdrawals: governancePendingWithdrawals, refetch: refetchGovernancePendingWithdrawals } = usePendingWithdrawals({ @@ -263,6 +266,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(
)} @@ -368,6 +377,9 @@ export const ATPDetailsModal = ({ atp, isOpen, onClose, onWithdrawSuccess, onRef rollupVersion={rollupVersion} atp={atp} onWithdrawSuccess={handleWithdrawSuccess} + atpType={atpType} + registryAddress={registryAddress} + milestoneId={milestoneId} /> ))}
@@ -458,6 +470,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..b1e2d39a6 --- /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. Withdrawals are disabled.", + [MilestoneStatus.Succeeded]: + "This milestone has been successfully achieved.", + }[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/components/SetOperatorModal/SetOperatorModal.tsx b/staking-dashboard/src/components/SetOperatorModal/SetOperatorModal.tsx index e26a3d3e8..e25fa54be 100644 --- a/staking-dashboard/src/components/SetOperatorModal/SetOperatorModal.tsx +++ b/staking-dashboard/src/components/SetOperatorModal/SetOperatorModal.tsx @@ -105,7 +105,7 @@ export default function SetOperatorModal({ {getTypeName(atp)} {atp.milestoneId !== undefined && ( - Milestone {atp.milestoneId.toString()} + Milestone {Number(atp.milestoneId) + 1} )}
diff --git a/staking-dashboard/src/components/VestingSchedule/VestingGraph.tsx b/staking-dashboard/src/components/VestingSchedule/VestingGraph.tsx index 169530181..027ae248e 100644 --- a/staking-dashboard/src/components/VestingSchedule/VestingGraph.tsx +++ b/staking-dashboard/src/components/VestingSchedule/VestingGraph.tsx @@ -3,6 +3,7 @@ import type { Address } from "viem" import { formatTokenAmount } from "@/utils/atpFormatters" import { useStakingAssetTokenDetails } from "@/hooks/stakingRegistry" import { useVestingCalculation } from "@/hooks/atp" +import { isAuctionRegistry } from "@/hooks/atpRegistry" interface VestingGraphProps { globalLock: { @@ -14,14 +15,19 @@ interface VestingGraphProps { atpType?: string registryAddress?: Address className?: string + /** Blockchain timestamp to use for "NOW" position. Falls back to Date.now() if not provided */ + blockTimestamp?: bigint } /** * SVG vector graph showing cliff vesting pattern */ -export const VestingGraph = ({ globalLock, className = "" }: VestingGraphProps) => { +export const VestingGraph = ({ globalLock, registryAddress, className = "", blockTimestamp }: VestingGraphProps) => { const { symbol, decimals } = useStakingAssetTokenDetails() + // Check if this is an ATP from auction registry + const isAuctionATP = isAuctionRegistry(registryAddress) + // Check for invalid time range const hasInvalidTimeRange = Number(globalLock.endTime) < Number(globalLock.startTime) @@ -46,7 +52,7 @@ export const VestingGraph = ({ globalLock, className = "" }: VestingGraphProps) ) } - const vestingData = useVestingCalculation(globalLock) + const vestingData = useVestingCalculation(globalLock, blockTimestamp) const graphData = useMemo(() => { // Graph dimensions @@ -60,53 +66,60 @@ export const VestingGraph = ({ globalLock, className = "" }: VestingGraphProps) const startTime = Number(globalLock.startTime) const cliffTime = Number(globalLock.cliff) const endTime = Number(globalLock.endTime) - const now = Math.floor(Date.now() / 1000) + // Use blockchain timestamp if provided, otherwise fall back to Date.now() + const now = blockTimestamp ? Number(blockTimestamp) : Math.floor(Date.now() / 1000) // Handle edge cases const startEqualsCliff = startTime === cliffTime const cliffEqualsEnd = cliffTime === endTime - // Calculate time range for X axis - const timeRange = endTime - startTime - const nowPosition = timeRange === 0 ? graphWidth / 2 : ((now - startTime) / timeRange) * graphWidth + // Calculate time range for X axis - include NOW if it's before startTime + const xAxisStart = Math.min(now, startTime) + const timeRange = endTime - xAxisStart + + // Calculate X positions relative to the full axis range + const nowPosition = timeRange === 0 ? 0 : ((now - xAxisStart) / timeRange) * graphWidth + const startX = timeRange === 0 ? 0 : ((startTime - xAxisStart) / timeRange) * graphWidth + + // Calculate cliff X position relative to full axis + const cliffX = timeRange === 0 ? startX : ((cliffTime - xAxisStart) / timeRange) * graphWidth // Create path points for cliff vesting pattern - handle collision cases + // Note: Path starts at startX (not 0) when NOW is before startTime let pathPoints = [] if (startEqualsCliff && cliffEqualsEnd) { // All three points are the same - instant vest pathPoints = [ - { x: 0, y: 0 }, // Start at 100% + { x: startX, y: 0 }, // Start at 100% { x: graphWidth, y: 0 } // End at 100% ] } else if (startEqualsCliff) { // Start = Cliff, but different from End - immediate cliff unlock then linear pathPoints = [ - { x: 0, y: graphHeight - (vestingData.cliffUnlockRatio * graphHeight) }, // Start at cliff % + { x: startX, y: graphHeight - (vestingData.cliffUnlockRatio * graphHeight) }, // Start at cliff % { x: graphWidth, y: 0 } // Linear to 100% at end ] } else if (cliffEqualsEnd) { // Cliff = End - stay at 0% until cliff, then jump to 100% - const cliffX = graphWidth pathPoints = [ - { x: 0, y: graphHeight }, // Start at 0% - { x: cliffX - 1, y: graphHeight }, // Stay at 0% until just before cliff - { x: cliffX, y: 0 } // Jump to 100% at cliff/end + { x: startX, y: graphHeight }, // Start at 0% + { x: graphWidth - 1, y: graphHeight }, // Stay at 0% until just before cliff + { x: graphWidth, y: 0 } // Jump to 100% at cliff/end ] } else { // Normal case - all three points are distinct - const cliffX = ((cliffTime - startTime) / timeRange) * graphWidth pathPoints = [ - { x: 0, y: graphHeight }, // Start at 0% + { x: startX, y: graphHeight }, // Start at 0% { x: cliffX, y: graphHeight }, // Stay at 0% until cliff { x: cliffX, y: graphHeight - (vestingData.cliffUnlockRatio * graphHeight) }, // Jump to cliff % { x: graphWidth, y: 0 } // Linear to 100% at end ] } - // Create SVG path + // Create SVG path - area starts at startX (which may be > 0 if now < startTime) const linePath = `M ${pathPoints.map(p => `${p.x},${p.y}`).join(' L ')}` - const areaPath = `M 0,${graphHeight} ${pathPoints.map(p => `L ${p.x},${p.y}`).join(' ')} L ${graphWidth},${graphHeight} Z` + const areaPath = `M ${startX},${graphHeight} ${pathPoints.map(p => `L ${p.x},${p.y}`).join(' ')} L ${graphWidth},${graphHeight} Z` // Calculate current vested position let currentY = graphHeight @@ -130,10 +143,8 @@ export const VestingGraph = ({ globalLock, className = "" }: VestingGraphProps) } } - const timeStartToCliff = Math.max(0, cliffTime - startTime) - const timeCliffToEnd = Math.max(0, endTime - cliffTime) - const timeNowToCliff = Math.max(0, cliffTime - now) - const timeNowToEnd = Math.max(0, endTime - now) + const timeNowToStart = Math.max(0, startTime - now) + const lockDuration = Math.max(0, endTime - startTime) return { width, @@ -149,18 +160,36 @@ export const VestingGraph = ({ globalLock, className = "" }: VestingGraphProps) startTime, cliffTime, endTime, - timeStartToCliff, - timeCliffToEnd, - timeNowToCliff, - timeNowToEnd, + startX, + cliffX, + timeNowToStart, + lockDuration, formatTimeRemaining, startEqualsCliff, - cliffEqualsEnd + cliffEqualsEnd, + nowBeforeStart: now < startTime } - }, [globalLock, vestingData]) + }, [globalLock, vestingData, blockTimestamp]) return (
+ {/* TGE Notice for Auction ATP */} + {isAuctionATP && ( +
+
+ TGE Notice: Tokens become available at TGE. TGE is decided by governance. Earliest anticipated in 90 days from start date. Latest is{' '} + + {new Date(Number(globalLock.endTime) * 1000).toLocaleDateString('en-US', { + day: 'numeric', + month: 'short', + year: 'numeric' + })} + + {' '}as shown in the graph below. +
+
+ )} + - {/* Show cliff labels on outer (left) side if ratio <= 0.6, inside graph if ratio > 0.6 */} - {vestingData.cliffUnlockRatio > 0.6 ? ( - <> - - Cliff Unlock - - - {formatTokenAmount(BigInt(Math.floor(Number(globalLock.allocation) * vestingData.cliffUnlockRatio)), decimals, symbol)} - - - {(vestingData.cliffUnlockRatio * 100).toFixed(2)}% - - - ) : ( - <> - - Cliff Unlock - - - {formatTokenAmount(BigInt(Math.floor(Number(globalLock.allocation) * vestingData.cliffUnlockRatio)), decimals, symbol)} - - - {(vestingData.cliffUnlockRatio * 100).toFixed(2)}% - - + {/* Show cliff labels only when cliff unlock ratio > 0 (to avoid overlapping with 0% labels) */} + {/* Position on outer (left) side if ratio <= 0.6, inside graph if ratio > 0.6 */} + {vestingData.cliffUnlockRatio > 0.01 && ( + vestingData.cliffUnlockRatio > 0.6 ? ( + <> + + Cliff Unlock + + + {formatTokenAmount(BigInt(Math.floor(Number(globalLock.allocation) * vestingData.cliffUnlockRatio)), decimals, symbol)} + + + {(vestingData.cliffUnlockRatio * 100).toFixed(2)}% + + + ) : ( + <> + + Cliff Unlock + + + {formatTokenAmount(BigInt(Math.floor(Number(globalLock.allocation) * vestingData.cliffUnlockRatio)), decimals, symbol)} + + + {(vestingData.cliffUnlockRatio * 100).toFixed(2)}% + + + ) )} - - 0 {symbol} - - - 0% - {/* Vesting area */} + {/* Horizontal line to Y-axis showing vested amount */} + {graphData.currentY < graphData.graphHeight && ( + <> + + {/* Vested amount label on Y-axis */} + + {formatTokenAmount(vestingData.currentVestedAmount, decimals, symbol)} + + + )} + {/* Current position dot */} - {/* Time remaining from NOW */} - - {/* NOW → Cliff */} - {graphData.timeNowToCliff > 0 && ( - - {graphData.formatTimeRemaining(graphData.timeNowToCliff)} → Cliff - - )} - - {/* NOW → End */} - {graphData.timeNowToEnd > 0 && ( - 0 ? "-15" : "-30"} - textAnchor="middle" - fontSize="10" - fill="#FF2DF4" - className="font-mono font-bold" - > - {graphData.formatTimeRemaining(graphData.timeNowToEnd)} → End - - )} - )} - {/* Time period labels - Always shown */} - {/* Start to Cliff duration */} - - - {graphData.formatTimeRemaining(graphData.timeStartToCliff)} - - - (Start → Cliff) - - + {/* Time period labels */} + {/* Now to Start - only show when now is before start */} + {graphData.nowBeforeStart && ( + + + {graphData.formatTimeRemaining(graphData.timeNowToStart)} + + + (Until Vesting Starts) + + + )} - {/* Cliff to End duration */} + {/* Lock Duration (Start to End) */} - {graphData.formatTimeRemaining(graphData.timeCliffToEnd)} + {graphData.formatTimeRemaining(graphData.lockDuration)} - (Cliff → End) + (Vesting Duration) - {/* Cliff unlock indicator */} - - - - {Math.round(vestingData.cliffUnlockRatio * 100)}% - - + {/* Cliff unlock indicator - only show when ratio > 0 */} + {vestingData.cliffUnlockRatio > 0.01 && ( + + + + {Math.round(vestingData.cliffUnlockRatio * 100)}% + + + )} {/* Date markers with vertical lines - Handle collisions */} {!graphData.startEqualsCliff && ( - Note: After cliff, tokens unlock linearly per Ethereum block. + Note: At the Unlock Start Time, tokens unlock linearly per Ethereum block.
) 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/atp/useVestingCalculation.ts b/staking-dashboard/src/hooks/atp/useVestingCalculation.ts index 5f28f0dff..80b28d2ff 100644 --- a/staking-dashboard/src/hooks/atp/useVestingCalculation.ts +++ b/staking-dashboard/src/hooks/atp/useVestingCalculation.ts @@ -27,12 +27,16 @@ interface VestingCalculation { /** * Calculate vesting timeline and amounts based on globalLock parameters + * @param globalLock - Vesting schedule parameters + * @param blockTimestamp - Optional blockchain timestamp to use instead of Date.now() */ export function useVestingCalculation( - globalLock: GlobalLock + globalLock: GlobalLock, + blockTimestamp?: bigint ): VestingCalculation { return useMemo(() => { - const now = getCurrentEpochSeconds() + // Use blockchain timestamp if provided, otherwise fall back to system time + const now = blockTimestamp ? Number(blockTimestamp) : getCurrentEpochSeconds() const timePoints: VestingTimePoint[] = [] const totalAmount = globalLock.allocation const vestingStartTime = Number(globalLock.startTime) @@ -111,5 +115,5 @@ export function useVestingCalculation( timeRemaining, currentVestedAmount } - }, [globalLock]) + }, [globalLock, blockTimestamp]) } \ No newline at end of file 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/atpFormatters.ts b/staking-dashboard/src/utils/atpFormatters.ts index 4cf4b4b5a..1bed60522 100644 --- a/staking-dashboard/src/utils/atpFormatters.ts +++ b/staking-dashboard/src/utils/atpFormatters.ts @@ -45,10 +45,15 @@ export function getStakeableAmount(atp: ATPData): bigint { /** * Calculate time until claimable + * @param atp - ATP data with globalLock info + * @param blockTimestamp - Optional blockchain timestamp to use instead of Date.now() + * This ensures consistency with anvil time warps during testing */ -export function getTimeToClaimForATP(atp: ATPData): string { +export function getTimeToClaimForATP(atp: ATPData, blockTimestamp?: bigint): string { if (atp.globalLock?.endTime) { - const now = Math.floor(Date.now() / 1000) + const now = blockTimestamp + ? Number(blockTimestamp) + : Math.floor(Date.now() / 1000) const endTime = Number(atp.globalLock.endTime) const timeLeft = endTime - now