Skip to content

Commit

Permalink
feat(staking): create a stake account on initial deposit
Browse files Browse the repository at this point in the history
  • Loading branch information
cprussin committed Sep 11, 2024
1 parent 4f25f63 commit 20ce372
Show file tree
Hide file tree
Showing 18 changed files with 738 additions and 641 deletions.
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 (
_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,
})}
/>
}
/>
<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

0 comments on commit 20ce372

Please sign in to comment.