diff --git a/apps/staking/src/api.ts b/apps/staking/src/api.ts index 65cc5b7cb..0b95a6a31 100644 --- a/apps/staking/src/api.ts +++ b/apps/staking/src/api.ts @@ -432,6 +432,16 @@ export const unstakeIntegrityStaking = async ( ); }; +export const unstakeAllIntegrityStaking = async ( + client: PythStakingClient, + stakeAccount: PublicKey, +): Promise => { + await client.unstakeFromAllPublishers(stakeAccount, [ + PositionState.LOCKED, + PositionState.LOCKING, + ]); +}; + export const reassignPublisherAccount = async ( client: PythStakingClient, stakeAccount: PublicKey, diff --git a/apps/staking/src/components/AccountSummary/index.tsx b/apps/staking/src/components/AccountSummary/index.tsx index fc0328dd9..4c2a12648 100644 --- a/apps/staking/src/components/AccountSummary/index.tsx +++ b/apps/staking/src/components/AccountSummary/index.tsx @@ -1,5 +1,7 @@ import { InformationCircleIcon } from "@heroicons/react/24/outline"; +import { epochToDate } from "@pythnetwork/staking-sdk"; import { useLocalStorageValue } from "@react-hookz/web"; +import clsx from "clsx"; import Image from "next/image"; import { type ComponentProps, @@ -7,6 +9,7 @@ import { type ReactNode, useCallback, useState, + useMemo, } from "react"; import { DialogTrigger, @@ -43,6 +46,11 @@ type Props = { expiringRewards: Date | undefined; availableToWithdraw: bigint; restrictedMode?: boolean | undefined; + integrityStakingWarmup: bigint; + integrityStakingStaked: bigint; + integrityStakingCooldown: bigint; + integrityStakingCooldown2: bigint; + currentEpoch: bigint; }; export const AccountSummary = ({ @@ -56,6 +64,11 @@ export const AccountSummary = ({ availableRewards, expiringRewards, restrictedMode, + integrityStakingWarmup, + integrityStakingStaked, + integrityStakingCooldown, + integrityStakingCooldown2, + currentEpoch, }: Props) => (
-
+
Total Balance @@ -170,6 +183,17 @@ export const AccountSummary = ({ )}
+ {restrictedMode && api.type === ApiStateType.Loaded && ( + + )}
} /> + {restrictedMode && api.type === ApiStateType.Loaded && ( + + )} {!restrictedMode && ( ); +type OisUnstakeProps = { + api: States[ApiStateType.Loaded]; + warmup: bigint; + staked: bigint; + cooldown: bigint; + cooldown2: bigint; + currentEpoch: bigint; + className?: string | undefined; +}; + +const OisUnstake = ({ + api, + warmup, + staked, + cooldown, + cooldown2, + currentEpoch, + className, +}: OisUnstakeProps) => { + const stakedPlusWarmup = useMemo(() => staked + warmup, [staked, warmup]); + const totalCooldown = useMemo( + () => cooldown + cooldown2, + [cooldown, cooldown2], + ); + const total = useMemo( + () => staked + warmup + cooldown + cooldown2, + [staked, warmup, cooldown, cooldown2], + ); + const { state, execute } = useAsync(api.unstakeAllIntegrityStaking); + + const doUnstakeAll = useCallback(() => { + execute().catch(() => { + /* TODO figure out a better UI treatment for when claim fails */ + }); + }, [execute]); + + // eslint-disable-next-line unicorn/no-null + return total === 0n ? null : ( + +

+ {stakedPlusWarmup > 0n ? ( + <> + You have tokens that are staked or in warmup to OIS. You are not + eligible to participate in OIS because you are in a restricted + region. Please unstake your tokens here and wait for the + cooldown. + + ) : ( + <>You have OIS tokens in cooldown. + )} +

+ {stakedPlusWarmup > 0n && totalCooldown > 0n && ( +

Cooldown Summary

+ )} + {cooldown > 0n && ( +
+ {cooldown} end{" "} + {epochToDate(currentEpoch + 2n).toLocaleString()} +
+ )} + {cooldown2 > 0n && ( +
+ {cooldown2} end{" "} + {epochToDate(currentEpoch + 1n).toLocaleString()} +
+ )} + + } + action={ + <> + {stakedPlusWarmup > 0n && ( + + )} + + } + /> + ); +}; + type WithdrawButtonProps = Omit< ComponentProps, "variant" | "actionDescription" | "actionName" | "transfer" @@ -245,19 +371,26 @@ const WithdrawButton = ({ api, ...props }: WithdrawButtonProps) => ( type BalanceCategoryProps = { name: string; amount: bigint; - description: string; + description: ReactNode; action: ReactNode; warning?: ReactNode | undefined; + className?: string | undefined; }; const BalanceCategory = ({ + className, name, amount, description, action, warning, }: BalanceCategoryProps) => ( -
+
{name} @@ -265,11 +398,11 @@ const BalanceCategory = ({
{amount}
-

{description}

+
{description}
{action} - {warning &&

{warning}

} + {warning &&
{warning}
}
); @@ -327,6 +460,7 @@ const ClaimDialog = ({ @@ -549,6 +551,7 @@ const OptOut = ({ api, self, ...props }: OptOut) => { variant="secondary" size="noshrink" isLoading={state.type === UseAsyncStateType.Running} + isDisabled={state.type === UseAsyncStateType.Complete} onPress={doOptOut} > Yes, opt me out diff --git a/apps/staking/src/components/TransferButton/index.tsx b/apps/staking/src/components/TransferButton/index.tsx index e9e8891a3..2ebedc4ff 100644 --- a/apps/staking/src/components/TransferButton/index.tsx +++ b/apps/staking/src/components/TransferButton/index.tsx @@ -229,7 +229,9 @@ const DialogContents = ({ className="mt-6 w-full" type="submit" isLoading={state.type === StateType.Running} - isDisabled={amount.type !== AmountType.Valid} + isDisabled={ + amount.type !== AmountType.Valid || state.type === StateType.Complete + } > {validationError ?? submitButtonText} diff --git a/apps/staking/src/hooks/use-api.tsx b/apps/staking/src/hooks/use-api.tsx index 7e744c511..8d6c41558 100644 --- a/apps/staking/src/hooks/use-api.tsx +++ b/apps/staking/src/hooks/use-api.tsx @@ -106,6 +106,7 @@ const State = { cancelWarmupIntegrityStaking: bindApi(api.cancelWarmupIntegrityStaking), reassignPublisherAccount: bindApi(api.reassignPublisherAccount), optPublisherOut: bindApi(api.optPublisherOut), + unstakeAllIntegrityStaking: bindApi(api.unstakeAllIntegrityStaking), }; }, diff --git a/governance/pyth_staking_sdk/src/pyth-staking-client.ts b/governance/pyth_staking_sdk/src/pyth-staking-client.ts index 3da87a3e3..c9467fabd 100644 --- a/governance/pyth_staking_sdk/src/pyth-staking-client.ts +++ b/governance/pyth_staking_sdk/src/pyth-staking-client.ts @@ -372,6 +372,45 @@ export class PythStakingClient { return sendTransaction(instructions, this.connection, this.wallet); } + public async unstakeFromAllPublishers( + stakeAccountPositions: PublicKey, + positionStates: (PositionState.LOCKED | PositionState.LOCKING)[], + ) { + const [stakeAccountPositionsData, currentEpoch] = await Promise.all([ + this.getStakeAccountPositions(stakeAccountPositions), + getCurrentEpoch(this.connection), + ]); + + const instructions = await Promise.all( + stakeAccountPositionsData.data.positions + .map((position, index) => { + const publisher = + position.targetWithParameters.integrityPool?.publisher; + return publisher === undefined + ? undefined + : { position, index, publisher }; + }) + // By separating this filter from the next, typescript can narrow the + // type and automatically infer that there will be no `undefined` values + // in the array after this line. If we combine those filters, + // typescript won't narrow properly. + .filter((positionInfo) => positionInfo !== undefined) + .filter(({ position }) => + (positionStates as PositionState[]).includes( + getPositionState(position, currentEpoch), + ), + ) + .map(({ position, index, publisher }) => + this.integrityPoolProgram.methods + .undelegate(index, convertBigIntToBN(position.amount)) + .accounts({ stakeAccountPositions, publisher }) + .instruction(), + ), + ); + + return sendTransaction(instructions, this.connection, this.wallet); + } + public async hasGovernanceRecord(config: GlobalConfig): Promise { const tokenOwnerRecordAddress = await getTokenOwnerRecordAddress( GOVERNANCE_ADDRESS,