diff --git a/biome.json b/biome.json index 46abf5884..63f7f6971 100644 --- a/biome.json +++ b/biome.json @@ -39,7 +39,8 @@ "noDuplicateArgumentNames": "error", "noDuplicatedSpreadProps": "error", "noFloatingPromises": "error", - "noShadow": "error" + "noShadow": "error", + "useSortedClasses": "error" } }, "domains": { diff --git a/frontend/example.env b/frontend/example.env new file mode 100644 index 000000000..1e106c80f --- /dev/null +++ b/frontend/example.env @@ -0,0 +1,10 @@ +# Required: which Solana cluster to connect to. +# Valid values: localnet, devnet, testnet, mainnet +NEXT_PUBLIC_CLUSTER=localnet + +# Optional: private/paid RPC URLs (server-side only, never exposed to the browser). +# Falls back to the public RPC URL for the selected cluster if not set. +# SERVER_LOCALNET_RPC_URL=http://localhost:8899 +# SERVER_DEVNET_RPC_URL=https://your-paid-rpc.example.com +# SERVER_TESTNET_RPC_URL=https://your-paid-rpc.example.com +# SERVER_MAINNET_RPC_URL=https://your-paid-rpc.example.com diff --git a/frontend/package.json b/frontend/package.json index 340e2e9fe..833ee9d70 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,13 +8,15 @@ "build": "next build", "check": "tsc --noEmit", "clean": "rm -rf .next", - "lint": "biome check .", + "lint": "biome check", "lint:fix": "pnpm lint --write", "format": "pnpm _format --write", "start": "next start" }, "dependencies": { "@base-ui/react": "^1.3.0", + "@solana/addresses": "catalog:", + "@solana/rpc-types": "catalog:", "@tanstack/react-query": "^5.95.2", "@tanstack/react-query-devtools": "^5.95.2", "class-variance-authority": "^0.7.1", diff --git a/frontend/public/icon.svg b/frontend/public/icon.svg new file mode 100644 index 000000000..40d5f0e7f --- /dev/null +++ b/frontend/public/icon.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 1db9ba22e..054e26f23 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -3,11 +3,21 @@ :root { --background: #ffffff; --foreground: #171717; + --muted: #f5f5f5; + --muted-fg: #737373; + --border: #e5e5e5; + --accent: #3b82f6; + --accent-hover: #2563eb; } @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); + --color-muted: var(--muted); + --color-muted-fg: var(--muted-fg); + --color-border: var(--border); + --color-accent: var(--accent); + --color-accent-hover: var(--accent-hover); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); } @@ -16,11 +26,16 @@ :root { --background: #0a0a0a; --foreground: #ededed; + --muted: #1a1a1a; + --muted-fg: #a3a3a3; + --border: #262626; + --accent: #60a5fa; + --accent-hover: #93bbfd; } } body { background: var(--background); color: var(--foreground); - font-family: var(--font-geist-sans); + font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif; } diff --git a/frontend/src/app/info/page.tsx b/frontend/src/app/info/page.tsx new file mode 100644 index 000000000..8cc436225 --- /dev/null +++ b/frontend/src/app/info/page.tsx @@ -0,0 +1,10 @@ +export default function InfoPage() { + return ( +
+
+

Info

+

Coming soon.

+
+
+ ); +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index eb0652b85..a1783bfdb 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,5 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; +import { Header } from "@/components/header"; +import { Providers } from "@/lib/providers"; import "./globals.css"; const geistSans = Geist({ @@ -13,8 +15,11 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Dropset", - description: "Dropset alpha prototype", + title: "dropset", + description: "dropset alpha prototype", + icons: { + icon: "/icon.svg", + }, }; export default function RootLayout({ @@ -27,7 +32,12 @@ export default function RootLayout({ lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} > - {children} + + +
+
{children}
+ + ); } diff --git a/frontend/src/app/market/[slug]/market-view.tsx b/frontend/src/app/market/[slug]/market-view.tsx new file mode 100644 index 000000000..3e0975664 --- /dev/null +++ b/frontend/src/app/market/[slug]/market-view.tsx @@ -0,0 +1,51 @@ +"use client"; + +import type { Address } from "@solana/addresses"; +import { useMarket } from "@/lib/hooks/use-market"; + +export function MarketView({ address }: { address: Address }) { + const { data: market, isLoading } = useMarket(address); + + return ( +
+
+

Market

+

+ {address} +

+
+ + {isLoading &&

Loading…

} + {!isLoading && !market &&

Couldn't load market

} + + {market && ( +
+
+ + Traders + + + {market.traders} + +
+
+ + Liquidity + + + ${market.liquidity.toLocaleString()} + +
+
+ + 24h Volume + + + ${market.volume24h.toLocaleString()} + +
+
+ )} +
+ ); +} diff --git a/frontend/src/app/market/[slug]/page.tsx b/frontend/src/app/market/[slug]/page.tsx new file mode 100644 index 000000000..72272d0a1 --- /dev/null +++ b/frontend/src/app/market/[slug]/page.tsx @@ -0,0 +1,37 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { fetchAllMarkets } from "@/lib/queries/fetch-all-markets"; +import { resolveSlug } from "@/lib/slug"; +import { MarketView } from "./market-view"; + +type Props = { + params: Promise<{ slug: string }>; +}; + +export async function generateMetadata({ params }: Props): Promise { + const { slug } = await params; + const markets = await fetchAllMarkets(); + const address = + resolveSlug( + slug, + markets.map((m) => m.address), + ) ?? notFound(); + + return { + title: `dropset – market ${address}`, + description: `A dropset alpha market at address ${address}`, + }; +} + +export default async function MarketPage({ params }: Props) { + const { slug } = await params; + const markets = await fetchAllMarkets(); + const address = resolveSlug( + slug, + markets.map((m) => m.address), + ); + + if (!address) notFound(); + + return ; +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 15403b8b8..7aabbd085 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,8 +1,96 @@ +"use client"; + +import Link from "next/link"; +import { useMemo } from "react"; +import { useAllMarkets } from "@/lib/hooks/use-all-markets"; +import { buildPrefixMap } from "@/lib/slug"; +import { ensureU64 } from "@/ts-sdk/rust-types"; + +function formatNumber(input: number | bigint): string { + const n = ensureU64(input); + if (n <= BigInt(Number.MAX_SAFE_INTEGER)) { + const val = Number(n); + if (val >= 1_000_000) return `${(val / 1_000_000).toFixed(1)}M`; + if (val >= 1_000) return `${(val / 1_000).toFixed(1)}K`; + } else { + if (n >= 1_000_000n) return `${(Number(n / 1_000_000n)).toFixed(1)}M`; + if (n >= 1_000n) return `${(Number(n / 1_000n)).toFixed(1)}K`; + } + return n.toString(); +} + export default function Home() { + const { data: markets, isLoading, error } = useAllMarkets(); + + const addresses = useMemo( + () => markets?.map((m) => m.address) ?? [], + [markets], + ); + + const prefixMap = useMemo(() => buildPrefixMap(addresses), [addresses]); + return ( -
-

Dropset

-

Alpha prototype

+
+
+

Markets

+

Browse active Dropset markets

+
+ + {isLoading &&

Loading markets…

} + {error &&

Failed to load markets.

} + + {markets && ( +
+ + + + + + + + + + + {markets.map((market) => { + const shortSlug = + prefixMap.get(market.address) ?? market.address; + return ( + + + + + + + ); + })} + +
+ Address + + Traders + + Liquidity + + 24h Volume +
+ + + {market.address} + + + + {market.traders} + + ${formatNumber(market.liquidity)} + + ${formatNumber(market.volume24h)} +
+
+ )}
); } diff --git a/frontend/src/app/repo/page.tsx b/frontend/src/app/repo/page.tsx new file mode 100644 index 000000000..71e3e81ed --- /dev/null +++ b/frontend/src/app/repo/page.tsx @@ -0,0 +1,10 @@ +export default function RepoPage() { + return ( +
+
+

Repo

+

Coming soon.

+
+
+ ); +} diff --git a/frontend/src/app/team/page.tsx b/frontend/src/app/team/page.tsx new file mode 100644 index 000000000..aaaa93b47 --- /dev/null +++ b/frontend/src/app/team/page.tsx @@ -0,0 +1,10 @@ +export default function TeamPage() { + return ( +
+
+

Team

+

Coming soon.

+
+
+ ); +} diff --git a/frontend/src/components/header.tsx b/frontend/src/components/header.tsx new file mode 100644 index 000000000..681c4c4c3 --- /dev/null +++ b/frontend/src/components/header.tsx @@ -0,0 +1,50 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +const NAV_ITEMS = [ + { label: "Info", href: "/info" }, + { label: "Repo", href: "/repo" }, + { label: "Team", href: "/team" }, +]; + +export function Header() { + const pathname = usePathname(); + + return ( +
+
+ + Dropset + + + +
+
+ ); +} diff --git a/frontend/src/lib/env.ts b/frontend/src/lib/env.ts new file mode 100644 index 000000000..c72deae6b --- /dev/null +++ b/frontend/src/lib/env.ts @@ -0,0 +1,58 @@ +import type { ClusterUrl } from "@solana/rpc-types"; +import { getRpcClient, type RpcClient } from "@/ts-sdk"; + +export type Cluster = "localnet" | "devnet" | "testnet" | "mainnet"; + +const VALID_CLUSTERS: Set = new Set([ + "localnet", + "devnet", + "testnet", + "mainnet", +]); + +function loadCluster(): Cluster { + const raw = process.env.NEXT_PUBLIC_CLUSTER; + if (!raw) { + throw new Error("NEXT_PUBLIC_CLUSTER is not set"); + } + if (!VALID_CLUSTERS.has(raw)) { + throw new Error( + `NEXT_PUBLIC_CLUSTER="${raw}" is not valid. Expected: ${[...VALID_CLUSTERS].join(", ")}`, + ); + } + return raw as Cluster; +} + +export const CLUSTER: Cluster = loadCluster(); + +const PUBLIC_RPC_URLS: Record = { + localnet: "http://localhost:8899" as ClusterUrl, + devnet: "https://api.devnet.solana.com" as ClusterUrl, + testnet: "https://api.testnet.solana.com" as ClusterUrl, + mainnet: "https://api.mainnet-beta.solana.com" as ClusterUrl, +}; + +const PRIVATE_RPC_URLS: Record = { + localnet: process.env.SERVER_LOCALNET_RPC_URL as ClusterUrl | undefined, + devnet: process.env.SERVER_DEVNET_RPC_URL as ClusterUrl | undefined, + testnet: process.env.SERVER_TESTNET_RPC_URL as ClusterUrl | undefined, + mainnet: process.env.SERVER_MAINNET_RPC_URL as ClusterUrl | undefined, +}; + +/** Public RPC URL for the current cluster. Safe to expose to the browser. */ +export const PUBLIC_RPC_URL: ClusterUrl = PUBLIC_RPC_URLS[CLUSTER]; + +/** Private RPC URL if set, otherwise falls back to public. */ +export const RPC_URL: ClusterUrl = + PRIVATE_RPC_URLS[CLUSTER] ?? PUBLIC_RPC_URLS[CLUSTER]; + +/** + * Creates an RPC client using the private RPC URL if available, public otherwise. + * + * Since `SERVER_*` env vars are only available server-side (no `NEXT_PUBLIC_` prefix), + * this naturally splits behavior: server components get the paid/private RPC if it exists, + * client components fall back to the public endpoint. + */ +export function getRpcFromEnv(rpc?: RpcClient): RpcClient { + return rpc ?? getRpcClient({ clusterUrl: RPC_URL }); +} diff --git a/frontend/src/lib/hooks/use-all-markets.ts b/frontend/src/lib/hooks/use-all-markets.ts new file mode 100644 index 000000000..df07154e1 --- /dev/null +++ b/frontend/src/lib/hooks/use-all-markets.ts @@ -0,0 +1,12 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { fetchAllMarkets } from "@/lib/queries/fetch-all-markets"; +import { rpcClient } from "../rpc"; + +export function useAllMarkets() { + return useQuery({ + queryKey: ["markets"], + queryFn: () => fetchAllMarkets(rpcClient), + }); +} diff --git a/frontend/src/lib/hooks/use-market.ts b/frontend/src/lib/hooks/use-market.ts new file mode 100644 index 000000000..e03a059f9 --- /dev/null +++ b/frontend/src/lib/hooks/use-market.ts @@ -0,0 +1,14 @@ +"use client"; + +import type { Address } from "@solana/addresses"; +import { useQuery } from "@tanstack/react-query"; +import { fetchMarket } from "@/lib/queries/fetch-market"; +import { rpcClient } from "../rpc"; + +export function useMarket(address: Address) { + return useQuery({ + queryKey: ["market", address], + queryFn: () => fetchMarket(address, rpcClient), + enabled: !!address, + }); +} diff --git a/frontend/src/lib/providers.tsx b/frontend/src/lib/providers.tsx new file mode 100644 index 000000000..2dfd0a95f --- /dev/null +++ b/frontend/src/lib/providers.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import dynamic from "next/dynamic"; + +const ReactQueryDevtools = dynamic( + () => + import("@tanstack/react-query-devtools").then( + (mod) => mod.ReactQueryDevtools, + ), + { ssr: false }, +); + +import { type ReactNode, useState } from "react"; + +export function Providers({ children }: { children: ReactNode }) { + const isDevelopment = + process.env.NODE_ENV === "development"; + + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + refetchOnWindowFocus: false, + }, + }, + }), + ); + + return ( + + {children} + {isDevelopment ? : null} + + ); +} diff --git a/frontend/src/lib/queries/fetch-all-markets.ts b/frontend/src/lib/queries/fetch-all-markets.ts new file mode 100644 index 000000000..748f07fb5 --- /dev/null +++ b/frontend/src/lib/queries/fetch-all-markets.ts @@ -0,0 +1,36 @@ +import type { Address } from "@solana/addresses"; +import { getRpcFromEnv } from "@/lib/env"; +import { + fetchDropsetMarketViews, + marketLiquidity, + type RpcClient, +} from "@/ts-sdk"; +import { fetchDailyVolume } from "./fetch-daily-volume"; + +export type MarketSummary = { + address: Address; + traders: number; + liquidity: bigint; + volume24h: bigint; +}; + +/** + * Fetch all markets from the Dropset program. + */ +export async function fetchAllMarkets( + rpc?: RpcClient, +): Promise { + const client = getRpcFromEnv(rpc); + const markets = await fetchDropsetMarketViews(client); + + const marketData = await Promise.all( + markets.map(async (m) => ({ + address: m.address, + traders: m.users.size, + liquidity: marketLiquidity(m).total, + volume24h: (await fetchDailyVolume(m.address)) ?? 0n, + })), + ); + + return marketData; +} diff --git a/frontend/src/lib/queries/fetch-daily-volume.ts b/frontend/src/lib/queries/fetch-daily-volume.ts new file mode 100644 index 000000000..00e8e6ee3 --- /dev/null +++ b/frontend/src/lib/queries/fetch-daily-volume.ts @@ -0,0 +1,16 @@ +import type { Address } from "@solana/addresses"; + +/** + * Fetches a market's daily volume. + */ +export async function fetchDailyVolume( + _marketAddress: Address, +): Promise { + if (process.env.NODE_ENV === "development") { + // Return a non-deterministic, randomly generated value in a development environment. + return BigInt(Math.trunc(Math.random() * 100_000_000)); + } else { + // Stub deterministically in a non-dev environment to avoid caching random values on real infra. + return 0n; + } +} diff --git a/frontend/src/lib/queries/fetch-market.ts b/frontend/src/lib/queries/fetch-market.ts new file mode 100644 index 000000000..c85ad85c9 --- /dev/null +++ b/frontend/src/lib/queries/fetch-market.ts @@ -0,0 +1,36 @@ +import type { Address } from "@solana/addresses"; +import { getRpcFromEnv } from "@/lib/env"; +import { + fetchDropsetMarketView, + marketLiquidity, + type RpcClient, +} from "@/ts-sdk"; +import { fetchDailyVolume } from "./fetch-daily-volume"; + +export type MarketDetail = { + address: Address; + traders: number; + liquidity: bigint; + volume24h: bigint; +}; + +/** + * Fetch a single market by its full address. + */ +export async function fetchMarket( + address: Address, + rpc?: RpcClient, +): Promise { + const rpcClient = getRpcFromEnv(rpc); + const market = await fetchDropsetMarketView(rpcClient, address); + if (!market) return undefined; + + const volume24h = (await fetchDailyVolume(address)) ?? 0n; + + return { + address, + traders: market.users.size, + liquidity: marketLiquidity(market).total, + volume24h, + }; +} diff --git a/frontend/src/lib/rpc.ts b/frontend/src/lib/rpc.ts new file mode 100644 index 000000000..0592f02c2 --- /dev/null +++ b/frontend/src/lib/rpc.ts @@ -0,0 +1,3 @@ +import { getRpcFromEnv } from "./env"; + +export const rpcClient = getRpcFromEnv(); diff --git a/frontend/src/lib/slug.ts b/frontend/src/lib/slug.ts new file mode 100644 index 000000000..2e02c9a2c --- /dev/null +++ b/frontend/src/lib/slug.ts @@ -0,0 +1,44 @@ +import type { Address } from "@solana/addresses"; + +/** + * Compute the shortest unique prefix for each address in the set. + * Returns a map from full address → shortest unambiguous prefix. + * Minimum prefix length is 6 characters. + */ +export function buildPrefixMap(addresses: Address[]): Map { + const MIN_LEN = 6; + const sorted = [...addresses].sort(); + const map = new Map(); + + for (let i = 0; i < sorted.length; i++) { + const addr = sorted[i]; + const prev = sorted[i - 1] ?? ""; + const next = sorted[i + 1] ?? ""; + + // Find the length needed to distinguish from both neighbors. + let len = MIN_LEN; + while ( + len < addr.length && + (addr.slice(0, len) === prev.slice(0, len) || + addr.slice(0, len) === next.slice(0, len)) + ) { + len++; + } + map.set(addr, addr.slice(0, len)); + } + + return map; +} + +/** + * Resolve a (possibly truncated) slug to a full market address. + * Returns the full address if exactly one match, or null if ambiguous / none. + */ +export function resolveSlug( + slug: string, + addresses: Address[], +): Address | null { + const matches = addresses.filter((a) => a.startsWith(slug)); + if (matches.length === 1) return matches[0]; + return null; +} diff --git a/package.json b/package.json index 66587932a..819dc6214 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "lint:fix": "pnpm run lint:ts:fix && pnpm run lint:rust:fix", "lint:ts": "biome check", "lint:ts:fix": "biome check --write", + "lint:ts:fix:unsafe": "biome check --write --unsafe", "lint:rust": "cargo +nightly clippy --workspace --all-targets -- -D warnings", "lint:rust:fix": "cargo +nightly clippy --fix --workspace --all-targets --allow-dirty --allow-staged -- -D warnings", "lint:generated": "biome check --write codama-idl-gen ts-sdk/src/generated", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e68a9e7ba..e93d54139 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,9 +12,15 @@ catalogs: '@jest/globals': specifier: ^29.7.0 version: 29.7.0 + '@solana/addresses': + specifier: ^6.1.0 + version: 6.7.0 '@solana/kit': specifier: ^6.1.0 version: 6.7.0 + '@solana/rpc-types': + specifier: ^6.1.0 + version: 6.7.0 '@types/node': specifier: ^24 version: 24.12.0 @@ -53,6 +59,12 @@ importers: '@base-ui/react': specifier: ^1.3.0 version: 1.3.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@solana/addresses': + specifier: 'catalog:' + version: 6.7.0(typescript@5.9.3) + '@solana/rpc-types': + specifier: 'catalog:' + version: 6.7.0(typescript@5.9.3) '@tanstack/react-query': specifier: ^5.95.2 version: 5.95.2(react@19.2.4) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 31b2432d2..0a64cdea6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,7 +9,9 @@ ignoredBuiltDependencies: catalog: "@biomejs/biome": 2.4.10 "@jest/globals": ^29.7.0 + "@solana/addresses": ^6.1.0 "@solana/kit": ^6.1.0 + "@solana/rpc-types": ^6.1.0 "@types/node": ^24 "decimal.js": ^10.6.0 "jest": ^29.7.0 diff --git a/ts-sdk/src/tests/e2e/dropset-accounts.test.ts b/ts-sdk/src/tests/e2e/dropset-accounts.test.ts index bd4f36619..c51e79a26 100644 --- a/ts-sdk/src/tests/e2e/dropset-accounts.test.ts +++ b/ts-sdk/src/tests/e2e/dropset-accounts.test.ts @@ -1,35 +1,74 @@ import { describe, expect, it } from "@jest/globals"; import { deriveMarketAddress, + fetchDropsetMarketAccount, fetchDropsetMarketAccounts, + fetchDropsetMarketView, fetchDropsetMarketViews, getRpcClient, } from "@/ts-sdk/utils"; +import { DROPSET_PROGRAM_ADDRESS } from "@/ts-sdk/generated/programs/dropset"; +import assert from "node:assert"; +import type { DropsetMarketAccount, DropsetMarketView } from "@/ts-sdk/types"; + +const deriveCheck = async ( + market: DropsetMarketAccount | DropsetMarketView, +) => { + const [derivedMarketAddress, _] = await deriveMarketAddress( + market.header.baseMint, + market.header.quoteMint, + ); + expect(derivedMarketAddress).toBe(market.address); +}; describe("Dropset market accounts", () => { it("should decode all dropset market accounts", async () => { const rpcClient = getRpcClient(); const markets = await fetchDropsetMarketAccounts(rpcClient); + expect(markets.length).toBeGreaterThanOrEqual(1); for (const market of markets) { - const [derivedMarketAddress, _] = await deriveMarketAddress( - market.header.baseMint, - market.header.quoteMint, - ); - expect(derivedMarketAddress).toBe(market.address); + await deriveCheck(market); } }); it("should decode all dropset market accounts into market views", async () => { const rpcClient = getRpcClient(); const markets = await fetchDropsetMarketViews(rpcClient); + expect(markets.length).toBeGreaterThanOrEqual(1); for (const market of markets) { - const [derivedMarketAddress, _] = await deriveMarketAddress( - market.header.baseMint, - market.header.quoteMint, - ); - expect(derivedMarketAddress).toBe(market.address); + await deriveCheck(market); } }); + + it("should decode one dropset market account", async () => { + const rpcClient = getRpcClient(); + const marketAddresses = await rpcClient + .getProgramAccounts(DROPSET_PROGRAM_ADDRESS, { encoding: "base64" }) + .send() + .then((markets) => markets.map((m) => m.pubkey)); + + expect(marketAddresses.length).toBeGreaterThanOrEqual(1); + const first = marketAddresses.at(0); + assert(first !== undefined); + const market = await fetchDropsetMarketAccount(rpcClient, first); + assert(market !== undefined); + await deriveCheck(market); + }); + + it("should decode one dropset market view", async () => { + const rpcClient = getRpcClient(); + const marketAddresses = await rpcClient + .getProgramAccounts(DROPSET_PROGRAM_ADDRESS, { encoding: "base64" }) + .send() + .then((markets) => markets.map((m) => m.pubkey)); + + expect(marketAddresses.length).toBeGreaterThanOrEqual(1); + const first = marketAddresses.at(0); + assert(first !== undefined); + const marketView = await fetchDropsetMarketView(rpcClient, first); + assert(marketView !== undefined); + await deriveCheck(marketView); + }); }); diff --git a/ts-sdk/src/utils/index.ts b/ts-sdk/src/utils/index.ts index fad47f3c9..7977eb366 100644 --- a/ts-sdk/src/utils/index.ts +++ b/ts-sdk/src/utils/index.ts @@ -20,6 +20,8 @@ import type { Flatten, } from "../types"; +export type RpcClient = NonNullable>; + type HttpTransportConfig = Flatten[0]>; type RpcClientArgs = { @@ -44,7 +46,7 @@ export function getRpcClient(args?: RpcClientArgs) { * Fetches the dropset market accounts owned by the dropset program. */ export async function fetchDropsetMarketAccounts( - rpcClient: ReturnType, + rpcClient: RpcClient, ): Promise { const markets = await rpcClient .getProgramAccounts(DROPSET_PROGRAM_ADDRESS, { encoding: "base64" }) @@ -61,12 +63,35 @@ export async function fetchDropsetMarketAccounts( ); } +/** + * Fetches a single dropset market account. + */ +export async function fetchDropsetMarketAccount( + rpcClient: RpcClient, + marketAddress: Address, +): Promise { + const market = await rpcClient + .getAccountInfo(marketAddress, { encoding: "base64" }) + .send(); + + const decoder = getMarketAccountDecoder(); + + if (!market.value) { + return undefined; + } + + return { + address: marketAddress, + ...decoder.decode(Buffer.from(market.value.data[0], "base64")), + }; +} + /** * Fetches the dropset market accounts owned by the dropset program and converts them into * more ergonomic {@link DropsetMarketView} types. */ export async function fetchDropsetMarketViews( - rpcClient: ReturnType, + rpcClient: RpcClient, ): Promise { return (await fetchDropsetMarketAccounts(rpcClient)).map( ({ address, ...market }) => ({ @@ -76,6 +101,23 @@ export async function fetchDropsetMarketViews( ); } +/** + * Fetches a single dropset market account and converts it into a more ergonomic + * {@link DropsetMarketView} type. + */ +export async function fetchDropsetMarketView( + rpcClient: RpcClient, + marketAddress: Address, +): Promise { + const account = await fetchDropsetMarketAccount(rpcClient, marketAddress); + if (!account) return undefined; + + return { + address: marketAddress, + ...toMarketViewAll(account), + }; +} + /** * Gets the derived market address given the base mint, quote mint, and dropset program * {@link Address}es.