Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(staking): create a stake account on initial deposit #1887

Merged
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
191 changes: 130 additions & 61 deletions apps/staking/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// TODO remove these disables when moving off the mock APIs
/* eslint-disable @typescript-eslint/no-unused-vars */

import type { HermesClient } from "@pythnetwork/hermes-client";
import type { HermesClient, PublisherCaps } from "@pythnetwork/hermes-client";
import {
epochToDate,
extractPublisherData,
Expand Down Expand Up @@ -38,7 +38,6 @@ type Data = {
expiry: Date;
}
| undefined;
locked: bigint;
unlockSchedule: {
date: Date;
amount: bigint;
Expand Down Expand Up @@ -141,7 +140,7 @@ export type AccountHistoryAction = ReturnType<
(typeof AccountHistoryAction)[keyof typeof AccountHistoryAction]
>;

type AccountHistory = {
export type AccountHistory = {
timestamp: Date;
action: AccountHistoryAction;
amount: bigint;
Expand All @@ -159,38 +158,59 @@ export const getStakeAccounts = async (
export const loadData = async (
client: PythStakingClient,
hermesClient: HermesClient,
stakeAccount: StakeAccountPositions,
stakeAccount?: StakeAccountPositions | undefined,
): Promise<Data> =>
stakeAccount === undefined
? loadDataNoStakeAccount(client, hermesClient)
: loadDataForStakeAccount(client, hermesClient, stakeAccount);

const loadDataNoStakeAccount = async (
client: PythStakingClient,
hermesClient: HermesClient,
): Promise<Data> => {
const { publishers, ...baseInfo } = await loadBaseInfo(client, hermesClient);

return {
...baseInfo,
lastSlash: undefined,
availableRewards: 0n,
expiringRewards: undefined,
total: 0n,
governance: {
warmup: 0n,
staked: 0n,
cooldown: 0n,
cooldown2: 0n,
},
unlockSchedule: [],
integrityStakingPublishers: publishers.map(
({ stakeAccount, ...publisher }) => ({
...publisher,
isSelf: false,
}),
),
};
};

const loadDataForStakeAccount = async (
client: PythStakingClient,
hermesClient: HermesClient,
stakeAccount: StakeAccountPositions,
) => {
const [
{ publishers, ...baseInfo },
stakeAccountCustody,
poolData,
ownerPythBalance,
unlockSchedule,
poolConfig,
claimableRewards,
currentEpoch,
publisherRankingsResponse,
publisherCaps,
] = await Promise.all([
loadBaseInfo(client, hermesClient),
client.getStakeAccountCustody(stakeAccount.address),
client.getPoolDataAccount(),
client.getOwnerPythBalance(),
client.getUnlockSchedule(stakeAccount.address),
client.getPoolConfigAccount(),
client.getClaimableRewards(stakeAccount.address),
getCurrentEpoch(client.connection),
fetch("/api/publishers-ranking"),
hermesClient.getLatestPublisherCaps({
parsed: true,
}),
]);

const publishers = extractPublisherData(poolData);

const publisherRankings = publishersRankingSchema.parse(
await publisherRankingsResponse.json(),
);

const filterGovernancePositions = (positionState: PositionState) =>
getAmountByTargetAndState({
stakeAccountPositions: stakeAccount,
Expand All @@ -210,71 +230,105 @@ export const loadData = async (
epoch: currentEpoch,
});

const getPublisherCap = (publisher: PublicKey) =>
BigInt(
publisherCaps.parsed?.[0]?.publisher_stake_caps.find(
({ publisher: p }) => p === publisher.toBase58(),
)?.cap ?? 0,
);

return {
...baseInfo,
lastSlash: undefined, // TODO
availableRewards: claimableRewards,
expiringRewards: undefined, // TODO
total: stakeAccountCustody.amount,
yieldRate: poolConfig.y,
governance: {
warmup: filterGovernancePositions(PositionState.LOCKING),
staked: filterGovernancePositions(PositionState.LOCKED),
cooldown: filterGovernancePositions(PositionState.PREUNLOCKING),
cooldown2: filterGovernancePositions(PositionState.UNLOCKED),
},
unlockSchedule,
locked: unlockSchedule.reduce((sum, { amount }) => sum + amount, 0n),
walletAmount: ownerPythBalance,
integrityStakingPublishers: publishers.map((publisherData) => {
const publisherPubkeyString = publisherData.pubkey.toBase58();
const publisherRanking = publisherRankings.find(
(ranking) => ranking.publisher === publisherPubkeyString,
);
const apyHistory = publisherData.apyHistory.map(({ epoch, apy }) => ({
date: epochToDate(epoch + 1n),
apy: Number(apy),
}));
return {
apyHistory,
isSelf:
publisherData.stakeAccount?.equals(stakeAccount.address) ?? false,
name: undefined, // TODO
numFeeds: publisherRanking?.numSymbols ?? 0,
poolCapacity: getPublisherCap(publisherData.pubkey),
poolUtilization: publisherData.totalDelegation,
publicKey: publisherData.pubkey,
qualityRanking: publisherRanking?.rank ?? 0,
selfStake: publisherData.selfDelegation,
integrityStakingPublishers: publishers.map(
({ stakeAccount: publisherStakeAccount, ...publisher }) => ({
...publisher,
isSelf: publisherStakeAccount?.equals(stakeAccount.address) ?? false,
positions: {
warmup: filterOISPositions(
publisherData.pubkey,
publisher.publicKey,
PositionState.LOCKING,
),
staked: filterOISPositions(
publisherData.pubkey,
PositionState.LOCKED,
),
staked: filterOISPositions(publisher.publicKey, PositionState.LOCKED),
cooldown: filterOISPositions(
publisherData.pubkey,
publisher.publicKey,
PositionState.PREUNLOCKING,
),
cooldown2: filterOISPositions(
publisherData.pubkey,
publisher.publicKey,
PositionState.UNLOCKED,
),
},
};
}),
}),
),
};
};

const loadBaseInfo = async (
client: PythStakingClient,
hermesClient: HermesClient,
) => {
const [publishers, walletAmount, poolConfig] = await Promise.all([
loadPublisherData(client, hermesClient),
client.getOwnerPythBalance(),
client.getPoolConfigAccount(),
]);

return { yieldRate: poolConfig.y, walletAmount, publishers };
};

const loadPublisherData = async (
client: PythStakingClient,
hermesClient: HermesClient,
) => {
const [poolData, publisherRankings, publisherCaps] = await Promise.all([
client.getPoolDataAccount(),
getPublisherRankings(),
hermesClient.getLatestPublisherCaps({
parsed: true,
}),
]);

return extractPublisherData(poolData).map((publisher) => {
const publisherPubkeyString = publisher.pubkey.toBase58();
const publisherRanking = publisherRankings.find(
(ranking) => ranking.publisher === publisherPubkeyString,
);
const apyHistory = publisher.apyHistory.map(({ epoch, apy }) => ({
date: epochToDate(epoch + 1n),
apy: Number(apy),
}));

return {
apyHistory,
name: undefined, // TODO
numFeeds: publisherRanking?.numSymbols ?? 0,
poolCapacity: getPublisherCap(publisherCaps, publisher.pubkey),
poolUtilization: publisher.totalDelegation,
publicKey: publisher.pubkey,
qualityRanking: publisherRanking?.rank ?? 0,
selfStake: publisher.selfDelegation,
stakeAccount: publisher.stakeAccount,
};
});
};

const getPublisherRankings = async () => {
const response = await fetch("/api/publishers-ranking");
const responseAsJson: unknown = await response.json();
return publishersRankingSchema.parseAsync(responseAsJson);
};

const getPublisherCap = (publisherCaps: PublisherCaps, publisher: PublicKey) =>
BigInt(
publisherCaps.parsed?.[0]?.publisher_stake_caps.find(
({ publisher: p }) => p === publisher.toBase58(),
)?.cap ?? 0,
);

export const loadAccountHistory = async (
_client: PythStakingClient,
_stakeAccount: PublicKey,
Expand All @@ -283,6 +337,14 @@ export const loadAccountHistory = async (
return mkMockHistory();
};

export const createStakeAccountAndDeposit = async (
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_client: PythStakingClient,
_amount: bigint,
): Promise<StakeAccountPositions> => {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY));
throw new NotImplementedError();
};

export const deposit = async (
client: PythStakingClient,
stakeAccount: PublicKey,
Expand Down Expand Up @@ -431,3 +493,10 @@ const mkMockHistory = (): AccountHistory => [
locked: 0n,
},
];

class NotImplementedError extends Error {
constructor() {
super("Not yet implemented!");
this.name = "NotImplementedError";
}
}
9 changes: 6 additions & 3 deletions apps/staking/src/components/AccountHistory/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ import {
AccountHistoryItemType,
StakeType,
} from "../../api";
import { StateType, useAccountHistory } from "../../hooks/use-account-history";
import type { States, StateType as ApiStateType } from "../../hooks/use-api";
import { StateType, useData } from "../../hooks/use-data";
import { Tokens } from "../Tokens";

export const AccountHistory = () => {
const history = useAccountHistory();
type Props = { api: States[ApiStateType.Loaded] };

export const AccountHistory = ({ api }: Props) => {
const history = useData(api.accountHisoryCacheKey, api.loadAccountHistory);

switch (history.type) {
case StateType.NotLoaded:
Expand Down
41 changes: 27 additions & 14 deletions apps/staking/src/components/AccountSummary/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@ import {
} from "react-aria-components";

import background from "./background.png";
import { deposit, withdraw, claim } from "../../api";
import { StateType, useTransfer } from "../../hooks/use-transfer";
import { type States, StateType as ApiStateType } from "../../hooks/use-api";
import { StateType, useAsync } from "../../hooks/use-async";
import { Button } from "../Button";
import { ModalDialog } from "../ModalDialog";
import { Tokens } from "../Tokens";
import { TransferButton } from "../TransferButton";

type Props = {
api: States[ApiStateType.Loaded] | States[ApiStateType.LoadedNoStakeAccount];
total: bigint;
locked: bigint;
unlockSchedule: {
Expand All @@ -38,6 +39,7 @@ type Props = {
};

export const AccountSummary = ({
api,
locked,
unlockSchedule,
lastSlash,
Expand Down Expand Up @@ -114,7 +116,7 @@ export const AccountSummary = ({
actionDescription="Add funds to your balance"
actionName="Add Tokens"
max={walletAmount}
transfer={deposit}
transfer={api.deposit}
/>
</div>
</div>
Expand All @@ -130,16 +132,25 @@ export const AccountSummary = ({
actionDescription="Move funds from your account back to your wallet"
actionName="Withdraw"
max={availableToWithdraw}
transfer={withdraw}
isDisabled={availableToWithdraw === 0n}
{...(api.type === ApiStateType.Loaded && {
transfer: api.withdraw,
})}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note to reviewer: in this PR I made the <TransferButton> component render a disabled button if the max is 0n or if transfer is undefined. That makes it a lot easier to just use it all over and let it disable itself correctly if there's no function to call (generally meaning the API is in a state where this transfer is impossible) or if there's no tokens to transfer. So you'll see here and a few other places where I removed some checks that control the button states, since those are now handled by the button itself.

/>
}
/>
<BalanceCategory
name="Available Rewards"
amount={availableRewards}
description="Rewards you have earned from OIS"
action={<ClaimButton isDisabled={availableRewards === 0n} />}
action={
api.type === ApiStateType.Loaded ? (
<ClaimButton isDisabled={availableRewards === 0n} api={api} />
) : (
<Button size="small" variant="secondary" isDisabled={true}>
Claim
</Button>
)
}
{...(expiringRewards !== undefined &&
expiringRewards.amount > 0n && {
warning: (
Expand Down Expand Up @@ -187,13 +198,15 @@ const BalanceCategory = ({
</div>
);

const ClaimButton = (
props: Omit<
ComponentProps<typeof Button>,
"onClick" | "disabled" | "loading"
>,
) => {
const { state, execute } = useTransfer(claim);
type ClaimButtonProps = Omit<
ComponentProps<typeof Button>,
"onClick" | "disabled" | "loading"
> & {
api: States[ApiStateType.Loaded];
};

const ClaimButton = ({ api, ...props }: ClaimButtonProps) => {
const { state, execute } = useAsync(api.claim);

const doClaim = useCallback(() => {
execute().catch(() => {
Expand All @@ -207,7 +220,7 @@ const ClaimButton = (
variant="secondary"
onPress={doClaim}
isDisabled={state.type !== StateType.Base}
isLoading={state.type === StateType.Submitting}
isLoading={state.type === StateType.Running}
{...props}
>
Claim
Expand Down
Loading
Loading