From cb1d73bd69dd9aa4e195a887f674bde19c9511bb Mon Sep 17 00:00:00 2001 From: Yuru Shao Date: Tue, 27 Aug 2024 22:25:21 -0700 Subject: [PATCH] [playground] display onchain stake accounts (#206) --- anchor/src/client/marinade.ts | 46 +++++++++++++++ anchor/src/client/staking.ts | 47 ++++++++++++++++ anchor/src/react/glam.tsx | 2 +- api/src/main.ts | 3 + api/src/routers/fund.ts | 10 +++- .../src/app/stake/components/columns.tsx | 31 +++++++--- .../components/data-table-row-actions.tsx | 21 ++++--- .../stake/components/data-table-toolbar.tsx | 6 +- playground/src/app/stake/data/data.tsx | 10 +++- playground/src/app/stake/data/schema.ts | 18 ++++++ playground/src/app/stake/data/testTickets.tsx | 20 ------- playground/src/app/stake/data/ticketSchema.ts | 10 ---- playground/src/app/stake/page.tsx | 56 +++++++------------ 13 files changed, 194 insertions(+), 86 deletions(-) create mode 100644 playground/src/app/stake/data/schema.ts delete mode 100644 playground/src/app/stake/data/testTickets.tsx delete mode 100644 playground/src/app/stake/data/ticketSchema.ts diff --git a/anchor/src/client/marinade.ts b/anchor/src/client/marinade.ts index aa309d4a..acd613e3 100644 --- a/anchor/src/client/marinade.ts +++ b/anchor/src/client/marinade.ts @@ -99,6 +99,52 @@ export class MarinadeClient { return accounts.map((a) => a.pubkey); } + async getTickets(fundPDA: PublicKey): Promise< + { + address: PublicKey; + lamports: number; + createdEpoch: number; + isDue: boolean; + }[] + > { + // TicketAccount { + // stateAddress: web3.PublicKey; // offset 8 + // beneficiary: web3.PublicKey; // offset 40 + // lamportsAmount: BN; // offset 72 + // createdEpoch: BN; + // } + const accounts = + await this.base.provider.connection.getParsedProgramAccounts( + MARINADE_PROGRAM_ID, + { + filters: [ + { + dataSize: 88, + }, + { + memcmp: { + offset: 40, + bytes: this.base.getTreasuryPDA(fundPDA).toBase58(), + }, + }, + ], + } + ); + const currentEpoch = await this.base.provider.connection.getEpochInfo(); + return accounts.map((a) => { + const lamports = Number((a.account.data as Buffer).readBigInt64LE(72)); + const createdEpoch = Number( + (a.account.data as Buffer).readBigInt64LE(80) + ); + return { + address: a.pubkey, + lamports, + createdEpoch, + isDue: currentEpoch.epoch > createdEpoch, + }; + }); + } + getMarinadeState(): any { // The addresses are the same in mainnet and devnet: // https://docs.marinade.finance/developers/contract-addresses diff --git a/anchor/src/client/staking.ts b/anchor/src/client/staking.ts index b2e49fff..751b2916 100644 --- a/anchor/src/client/staking.ts +++ b/anchor/src/client/staking.ts @@ -7,6 +7,7 @@ import { SYSVAR_CLOCK_PUBKEY, SYSVAR_STAKE_HISTORY_PUBKEY, STAKE_CONFIG_ID, + ParsedAccountData, } from "@solana/web3.js"; import { BaseClient, ApiTxOptions } from "./base"; @@ -23,6 +24,12 @@ interface StakePoolAccountData { validatorList: PublicKey; } +type StakeAccountInfo = { + address: PublicKey; + lamports: number; + state: string; +}; + export class StakingClient { public constructor(readonly base: BaseClient) {} @@ -172,6 +179,46 @@ export class StakingClient { .map((a) => a.pubkey); } + async getStakeAccountsWithStates( + withdrawAuthority: PublicKey + ): Promise { + const STAKE_ACCOUNT_SIZE = 200; + const accounts = + await this.base.provider.connection.getParsedProgramAccounts( + StakeProgram.programId, + { + filters: [ + { + dataSize: STAKE_ACCOUNT_SIZE, + }, + { + memcmp: { + offset: 12, + bytes: withdrawAuthority.toBase58(), + }, + }, + ], + } + ); + + const stakes = await Promise.all( + accounts.map(async (account) => { + const stakeInfo = + await this.base.provider.connection.getStakeActivation( + account.pubkey + ); + return { + address: account.pubkey, + lamports: account.account.lamports, + state: stakeInfo.state, + }; + }) + ); + + // order by lamports desc + return stakes.sort((a, b) => b.lamports - a.lamports); + } + async getStakePoolAccountData( stakePool: PublicKey ): Promise { diff --git a/anchor/src/react/glam.tsx b/anchor/src/react/glam.tsx index 612d422d..8a8612a0 100644 --- a/anchor/src/react/glam.tsx +++ b/anchor/src/react/glam.tsx @@ -71,7 +71,7 @@ export function GlamProvider({ ).then((res) => res.json()), enabled: !!wallet.publicKey, staleTime: 1000 * 60 * 5, // 5 minutes - refetchInterval: 1000 * 10, // 10 seconds + refetchInterval: 1000 * 30, // 10 seconds }); useEffect(() => { diff --git a/api/src/main.ts b/api/src/main.ts index 60e767d7..4eca9a59 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -55,6 +55,9 @@ app.use((req, res, next) => { if (process.env.GAE_SERVICE && req.hostname !== "api.glam.systems") { req.client = devnetClient; } + if (process.env.NODE_ENV === "development") { + console.log(`${new Date().toISOString()} ${req.method} ${req.originalUrl}`); + } next(); }); diff --git a/api/src/routers/fund.ts b/api/src/routers/fund.ts index c5486a01..2e835482 100644 --- a/api/src/routers/fund.ts +++ b/api/src/routers/fund.ts @@ -149,11 +149,19 @@ router.get("/funds/:pubkey/perf", async (req, res) => { router.get("/funds/:pubkey/tickets", async (req, res) => { const fund = validatePubkey(req.params.pubkey); - const tickets = await req.client.marinade.getExistingTickets(fund); + const tickets = await req.client.marinade.getTickets(fund); res.set("content-type", "application/json"); res.send({ tickets }); }); +router.get("/funds/:pubkey/stakes", async (req, res) => { + const fund = validatePubkey(req.params.pubkey); + const treasury = req.client.getTreasuryPDA(fund); + const stakes = await req.client.staking.getStakeAccountsWithStates(treasury); + res.set("content-type", "application/json"); + res.send({ stakes }); +}); + router.get("/funds/:pubkey/metadata", async (req, res) => { const pubkey = validatePubkey(req.params.pubkey); if (!pubkey) { diff --git a/playground/src/app/stake/components/columns.tsx b/playground/src/app/stake/components/columns.tsx index 6bb6ee10..8930117e 100644 --- a/playground/src/app/stake/components/columns.tsx +++ b/playground/src/app/stake/components/columns.tsx @@ -6,13 +6,13 @@ import { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; import { types, statuses } from "../data/data"; -import { Ticket } from "../data/ticketSchema"; +import { TicketOrStake } from "../data/schema"; import { DataTableColumnHeader } from "./data-table-column-header"; import { DataTableRowActions } from "./data-table-row-actions"; import TruncateAddress from "../../../utils/TruncateAddress"; -export const columns: ColumnDef[] = [ +export const columns: ColumnDef[] = [ { id: "select", header: ({ table }) => ( @@ -43,7 +43,21 @@ export const columns: ColumnDef[] = [ ), cell: ({ row }) => { - return + return ; + }, + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "lamports", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const formatted = new Intl.NumberFormat("en-US").format( + row.getValue("lamports") + ); + return

{formatted}

; }, enableSorting: false, enableHiding: false, @@ -58,9 +72,12 @@ export const columns: ColumnDef[] = [ return (
- {type && {type.label}} - - + {type && ( + + {type.label} + + )} +
); }, @@ -80,7 +97,7 @@ export const columns: ColumnDef[] = [ } return ( -
+
{status.icon && ( )} diff --git a/playground/src/app/stake/components/data-table-row-actions.tsx b/playground/src/app/stake/components/data-table-row-actions.tsx index c196c63e..bcded24e 100644 --- a/playground/src/app/stake/components/data-table-row-actions.tsx +++ b/playground/src/app/stake/components/data-table-row-actions.tsx @@ -4,7 +4,7 @@ import { ResetIcon } from "@radix-ui/react-icons"; import { Row } from "@tanstack/react-table"; import { Button } from "@/components/ui/button"; import { PublicKey } from "@solana/web3.js"; -import { ticketSchema } from "../data/ticketSchema"; +import { ticketOrStakeSchema } from "../data/schema"; import { useGlam } from "@glam/anchor/react"; import { testFund } from "../../testFund"; import { toast } from "@/components/ui/use-toast"; @@ -17,20 +17,25 @@ interface DataTableRowActionsProps { export function DataTableRowActions({ row, }: DataTableRowActionsProps) { - const ticket = ticketSchema.parse(row.original); - const isClaimable = ticket.status === "claimable"; + const ticketOrStake = ticketOrStakeSchema.parse(row.original); + const isClaimable = + ticketOrStake.status === "claimable" || ticketOrStake.status === "inactive"; - const { glamClient } = useGlam(); + const { glamClient, fund: fundPDA } = useGlam(); const handleClaim = async () => { + if (!fundPDA) { + console.error("No fund selected"); + return; + } + try { - const ticketPublicKey = new PublicKey(ticket.publicKey); - console.log("Test Claim Button"); + const ticketPublicKey = new PublicKey(ticketOrStake.publicKey); + console.log("Deactivating stake account:", ticketPublicKey.toBase58()); - const txId = await glamClient.marinade.claimTickets(testFund.fundPDA, [ + const txId = await glamClient.staking.deactivateStakeAccounts(fundPDA, [ ticketPublicKey, ]); - console.log("Claim successful"); toast({ title: "Claim Successful", diff --git a/playground/src/app/stake/components/data-table-toolbar.tsx b/playground/src/app/stake/components/data-table-toolbar.tsx index c2d89216..e79ba291 100644 --- a/playground/src/app/stake/components/data-table-toolbar.tsx +++ b/playground/src/app/stake/components/data-table-toolbar.tsx @@ -23,8 +23,10 @@ export function DataTableToolbar({
table.getColumn("publicKey")?.setFilterValue(event.target.value) } diff --git a/playground/src/app/stake/data/data.tsx b/playground/src/app/stake/data/data.tsx index a4766b3b..181de37f 100644 --- a/playground/src/app/stake/data/data.tsx +++ b/playground/src/app/stake/data/data.tsx @@ -12,7 +12,15 @@ export const types = [ { value: "liquid", label: "Liquid", - } + }, + { + value: "stake", + label: "Stake", + }, + { + value: "ticket", + label: "Ticket", + }, ]; export const statuses = [ diff --git a/playground/src/app/stake/data/schema.ts b/playground/src/app/stake/data/schema.ts new file mode 100644 index 00000000..d2093cb3 --- /dev/null +++ b/playground/src/app/stake/data/schema.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +export const stakeServiceSchema = z.object({ + service: z.enum(["Marinade", "Native", "Jito"]), + amountIn: z.number().nonnegative(), + amountInAsset: z.string(), +}); + +export const ticketOrStakeSchema = z.object({ + publicKey: z.string(), + lamports: z.number().nonnegative(), + service: z.string(), + status: z.string(), + label: z.string(), +}); + +export type StakeService = z.infer; +export type TicketOrStake = z.infer; diff --git a/playground/src/app/stake/data/testTickets.tsx b/playground/src/app/stake/data/testTickets.tsx deleted file mode 100644 index 9c79d037..00000000 --- a/playground/src/app/stake/data/testTickets.tsx +++ /dev/null @@ -1,20 +0,0 @@ -export const testTickets = [ - { - publicKey: "ABCNN...RdZX5", - service: "Marinade", - status: "pending", - label: "native", - }, - { - publicKey: "EfGBU...TLdQK", - service: "Jito", - status: "claimable", - label: "lst", - }, - { - publicKey: "123rm...6TnP6", - service: "Sanctum", - status: "canceled", - label: "lrt", - }, -]; diff --git a/playground/src/app/stake/data/ticketSchema.ts b/playground/src/app/stake/data/ticketSchema.ts deleted file mode 100644 index c1f48bf2..00000000 --- a/playground/src/app/stake/data/ticketSchema.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { z } from "zod"; - -export const ticketSchema = z.object({ - publicKey: z.string(), - service: z.string(), - status: z.string(), - label: z.string(), -}); - -export type Ticket = z.infer; diff --git a/playground/src/app/stake/page.tsx b/playground/src/app/stake/page.tsx index 374f8818..26c7710f 100644 --- a/playground/src/app/stake/page.tsx +++ b/playground/src/app/stake/page.tsx @@ -2,7 +2,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useForm, SubmitHandler, FormProvider, get } from "react-hook-form"; -import { z } from "zod"; import { Button } from "@/components/ui/button"; import { @@ -34,31 +33,14 @@ import { useGlam, JITO_STAKE_POOL, MSOL, JITOSOL } from "@glam/anchor/react"; import { ExplorerLink } from "@/components/ExplorerLink"; import { LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js"; import PageContentWrapper from "@/components/PageContentWrapper"; -import { publicKey } from "@solana/spl-stake-pool/dist/codecs"; +import { StakeService, stakeServiceSchema, TicketOrStake } from "./data/schema"; -const stakeSchema = z.object({ - service: z.enum(["Marinade", "Native", "Jito"]), - amountIn: z.number().nonnegative(), - amountInAsset: z.string(), -}); - -type StakeSchema = z.infer; - -const serviceToAssetMap: { [key in StakeSchema["service"]]: string } = { +const serviceToAssetMap: { [key in StakeService["service"]]: string } = { Marinade: "mSOL", Native: "SOL", Jito: "jitoSOL", }; -const ticketSchema = z.object({ - publicKey: z.string(), - service: z.string(), - status: z.string(), - label: z.string(), -}); - -export type Ticket = z.infer; - export type assetBalancesMap = { [key: string]: number; }; @@ -66,7 +48,7 @@ export type assetBalancesMap = { export default function Stake() { const { fund: fundPDA, treasury, wallet, glamClient } = useGlam(); - const [ticketsAndStakes, setTicketsAndStakes] = useState([]); + const [ticketsAndStakes, setTicketsAndStakes] = useState([]); const [amountInAsset, setAmountInAsset] = useState("SOL"); const [mode, setMode] = useState("stake"); const [loading, setLoading] = useState(true); // New loading state @@ -79,22 +61,24 @@ export default function Stake() { return; } try { - const stakes = await glamClient.staking.getStakeAccounts( + const stakes = await glamClient.staking.getStakeAccountsWithStates( new PublicKey(treasury.address) ); const transformedStakes = stakes.map((stakeAccount) => ({ - publicKey: stakeAccount.toBase58(), + publicKey: stakeAccount.address.toBase58(), + lamports: stakeAccount.lamports, service: "native", - status: "claimable", - label: "liquid", + status: stakeAccount.state, + label: "stake", })); - const tickets = await glamClient.marinade.getExistingTickets(fundPDA); + const tickets = await glamClient.marinade.getTickets(fundPDA); const transformedTickets = tickets.map((ticket) => ({ - publicKey: ticket.toBase58(), + publicKey: ticket.address.toBase58(), + lamports: ticket.lamports, service: "marinade", - status: "claimable", - label: "liquid", + status: ticket.isDue ? "claimable" : "pending", + label: "ticket", })); setTicketsAndStakes(transformedTickets.concat(transformedStakes)); @@ -126,8 +110,8 @@ export default function Stake() { fetchData(); }, [glamClient, treasury, fundPDA]); - const form = useForm({ - resolver: zodResolver(stakeSchema), + const form = useForm({ + resolver: zodResolver(stakeServiceSchema), defaultValues: { service: "Marinade", amountIn: 0, @@ -135,8 +119,8 @@ export default function Stake() { }, }); - const onSubmit: SubmitHandler = async ( - values: StakeSchema, + const onSubmit: SubmitHandler = async ( + values: StakeService, event ) => { const nativeEvent = event as React.BaseSyntheticEvent & { @@ -253,7 +237,7 @@ export default function Stake() { setMode("stake"); }; - const handleServiceChange = (service: StakeSchema["service"]) => { + const handleServiceChange = (service: StakeService["service"]) => { form.setValue("service", service); if (mode === "unstake") { const asset = serviceToAssetMap[service]; @@ -329,7 +313,7 @@ export default function Stake() { value={field.value} onValueChange={(value) => handleServiceChange( - value as StakeSchema["service"] + value as StakeService["service"] ) } > @@ -337,7 +321,7 @@ export default function Stake() { - {stakeSchema.shape.service._def.values.map( + {stakeServiceSchema.shape.service._def.values.map( (option) => ( {option}