Skip to content
Closed
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
154 changes: 154 additions & 0 deletions app/__tests__/hooks/gh2414-portfolio-partial-scan.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* GH#2414 — the portfolio snapshot could publish an INCOMPLETE owner scan as if
* it had completed successfully.
*
* Every failure path inside usePortfolio's load() was swallowed into an empty
* result: a failed batch slab fetch, a failed per-market scan (which covers the
* v17 getProgramAccounts owner scan), and a throw from market discovery. That
* made "we could not read your positions" indistinguishable from "you have no
* positions", understating position count, deposited capital, portfolio value,
* unrealized PnL and liquidation risk.
*
* These tests pin the property that an incomplete scan reports itself as
* incomplete.
*/

import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { PublicKey } from "@solana/web3.js";

const WALLET = new PublicKey("BXzwCWKsMpAW2MxWTWPaJu4fByYWkBFGBmLz4QxGUkwi");
const SLAB = new PublicKey("So11111111111111111111111111111111111111112");
const PROGRAM = new PublicKey("4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R");

const mockConnection = {
getMultipleAccountsInfo: vi.fn(),
getProgramAccounts: vi.fn(),
};

vi.mock("@/hooks/useWalletCompat", () => ({
useConnectionCompat: () => ({ connection: mockConnection }),
useWalletCompat: () => ({ publicKey: WALLET }),
}));

vi.mock("@/lib/config", () => ({
getAllProgramIds: () => [PROGRAM.toBase58()],
getNetwork: () => "devnet",
}));

// Discovery returns one market; the failure under test happens after this.
vi.mock("@/lib/market-directory-discovery", () => ({
discoverMarketsViaProgramDirectory: vi.fn(async () => [
{ slabAddress: SLAB, symbol: "SOL-PERP", name: "SOL/USD" },
]),
}));

vi.mock("@percolatorct/sdk", () => ({
discoverMarketsViaStaticBundle: vi.fn(async () => []),
parseAllAccounts: vi.fn(() => []),
parseConfig: vi.fn(() => ({ lastEffectivePriceE6: 1_000_000n, invert: false })),
parseParams: vi.fn(() => ({ maintenanceMarginBps: 500n })),
parsePortfolioV17: vi.fn(() => ({ capital: 0n, pnl: 0n, reservedPnl: 0n, legs: [] })),
parseWrapperConfigV17: vi.fn(() => ({ markEwmaE6: 1_000_000n })),
isV17Account: vi.fn(() => true),
AccountKind: { User: 0 },
computeLiqPrice: vi.fn(() => 0n),
computeMarkPnl: vi.fn(() => 0n),
computePnlPercent: vi.fn(() => 0),
V17_HEADER_LEN: 0,
}));

vi.mock("@/lib/health", () => ({ isSentinelValue: () => false }));
vi.mock("@/lib/oraclePrice", () => ({
applyInvert: (p: bigint) => p,
sanitizePriceE6: (p: bigint) => p,
}));
vi.mock("@/lib/entry-price", () => ({ getEntryPrice: () => 0n }));

async function getHook() {
const mod = await import("@/hooks/usePortfolio");
return mod.usePortfolio;
}

/** A slab account blob that isV17Account() will accept (mocked to true). */
function slabAccount() {
return { data: Buffer.alloc(512), owner: PROGRAM };
}

beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
mockConnection.getMultipleAccountsInfo.mockResolvedValue([slabAccount()]);
mockConnection.getProgramAccounts.mockResolvedValue([]);
});

describe("GH#2414 partial portfolio scan", () => {
it("flags the snapshot as partial when the v17 owner scan fails", async () => {
// The reported bug: getProgramAccounts fails for one v17 program while the
// rest of the scan succeeds.
mockConnection.getProgramAccounts.mockRejectedValue(new Error("RPC 429"));

const usePortfolio = await getHook();
const { result } = renderHook(() => usePortfolio());

await waitFor(() => expect(result.current.loading).toBe(false));

expect(result.current.isPartial).toBe(true);
expect(result.current.failedMarketCount).toBeGreaterThan(0);
});

it("flags the snapshot as partial when the batch slab fetch fails", async () => {
// Worst case: this covers every market, so the old code produced an empty
// portfolio that rendered as "you have no positions".
mockConnection.getMultipleAccountsInfo.mockRejectedValue(new Error("RPC down"));

const usePortfolio = await getHook();
const { result } = renderHook(() => usePortfolio());

await waitFor(() => expect(result.current.loading).toBe(false));

expect(result.current.isPartial).toBe(true);
expect(result.current.positions).toHaveLength(0);
// The critical distinction: zero positions AND a partial flag, so a
// consumer can tell this apart from a genuinely empty wallet.
expect(result.current.failedMarketCount).toBeGreaterThan(0);
});

it("reports a complete scan as NOT partial when everything succeeds", async () => {
const usePortfolio = await getHook();
const { result } = renderHook(() => usePortfolio());

await waitFor(() => expect(result.current.loading).toBe(false));

expect(result.current.isPartial).toBe(false);
expect(result.current.failedMarketCount).toBe(0);
});

it("an empty wallet is complete, not partial", async () => {
// Both calls succeed and simply find nothing — this must stay
// distinguishable from the failure cases above.
mockConnection.getMultipleAccountsInfo.mockResolvedValue([slabAccount()]);
mockConnection.getProgramAccounts.mockResolvedValue([]);

const usePortfolio = await getHook();
const { result } = renderHook(() => usePortfolio());

await waitFor(() => expect(result.current.loading).toBe(false));

expect(result.current.positions).toHaveLength(0);
expect(result.current.isPartial).toBe(false);
});

it("exposes isPartial and failedMarketCount on the published snapshot", async () => {
const usePortfolio = await getHook();
const { result } = renderHook(() => usePortfolio());

await waitFor(() => expect(result.current.loading).toBe(false));

// Guards the contract itself — consumers cannot read totals without these.
expect(result.current).toHaveProperty("isPartial");
expect(result.current).toHaveProperty("failedMarketCount");
expect(typeof result.current.isPartial).toBe("boolean");
expect(typeof result.current.failedMarketCount).toBe("number");
});
});
22 changes: 22 additions & 0 deletions app/app/portfolio/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ export default function PortfolioPage() {
const atRiskCount = portfolio.atRiskCount ?? 0;
const loading = mockPositions ? false : portfolio.loading;
const refresh = portfolio.refresh;
// GH#2414: an incomplete scan must not render as a complete picture — the
// totals below can understate real exposure. Mock mode is always complete.
const isPartial = mockPositions ? false : portfolio.isPartial;
const failedMarketCount = portfolio.failedMarketCount ?? 0;

// LP positions (insurance fund deposits)
const lpPositions = useLpPositions();
Expand Down Expand Up @@ -150,6 +154,24 @@ export default function PortfolioPage() {
</span>
)}
</p>
{/* GH#2414: the scan failed for at least one market, so positions
may be missing and every total shown below may be understated.
Say so explicitly — silently rendering a partial portfolio as
complete is what let users believe a position had closed. */}
{isPartial && !loading && (
<div
role="alert"
data-testid="portfolio-partial-warning"
className="mt-3 rounded-sm border border-[var(--warning,#f5a623)]/40 bg-[var(--warning,#f5a623)]/10 px-3 py-2 text-[12px] text-[var(--text)]"
>
<span className="font-bold">⚠ Incomplete portfolio.</span>{" "}
{failedMarketCount > 0
? `${failedMarketCount} market${failedMarketCount === 1 ? "" : "s"} could not be loaded.`
: "Some markets could not be loaded."}{" "}
Positions may be missing and the totals below may be understated. Use Refresh, and
check the market page directly before acting on these numbers.
</div>
)}
</div>
{refresh && (
<button
Expand Down
56 changes: 51 additions & 5 deletions app/hooks/usePortfolio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,15 @@ export interface PortfolioData {
loading: boolean;
/** True only during background refreshes (not initial load) */
isRefreshing: boolean;
/**
* GH#2414: true when at least one market could not be scanned, so this
* snapshot is INCOMPLETE. Positions may be missing and every total below
* (deposited, value, PnL, atRiskCount) may be understated. Callers must not
* present a partial snapshot as a complete picture of the wallet's exposure.
*/
isPartial: boolean;
/** GH#2414: how many markets failed to scan. 0 when isPartial is false. */
failedMarketCount: number;
refresh: () => void;
}

Expand All @@ -132,6 +141,10 @@ export function usePortfolio(): PortfolioData {
const [atRiskCount, setAtRiskCount] = useState(0);
const [loading, setLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
// GH#2414: incompleteness must be part of the published snapshot, not a
// console.error nobody sees.
const [isPartial, setIsPartial] = useState(false);
const [failedMarketCount, setFailedMarketCount] = useState(0);
const hasLoadedOnce = useRef(false);
const [refreshCounter, setRefreshCounter] = useState(0);

Expand All @@ -156,6 +169,10 @@ export function usePortfolio(): PortfolioData {
setAtRiskCount(0);
setLoading(false);
setIsRefreshing(false);
// No wallet is a COMPLETE (empty) picture, not a failed scan — clear any
// partial flag left over from a previously connected wallet (GH#2414).
setIsPartial(false);
setFailedMarketCount(0);
hasLoadedOnce.current = false;
return;
}
Expand All @@ -180,6 +197,11 @@ export function usePortfolio(): PortfolioData {
let depositSum = 0n;
let unrealizedPnlSum = 0n;
let riskCount = 0;
// GH#2414: every failure below used to be swallowed into an empty
// result, making an incomplete scan indistinguishable from a wallet
// with no positions. Count them so the snapshot can declare itself
// partial instead of silently understating the user's exposure.
let failedMarkets = 0;

// Batch fetch all slab accounts using getMultipleAccountsInfo
// RPC limit is 100 accounts per call, so chunk into batches
Expand All @@ -197,8 +219,12 @@ export function usePortfolio(): PortfolioData {
);
slabAccountsInfo = results.flat();
} catch (error) {
// GH#2414: this is the worst case — the batch slab fetch covers EVERY
// market, so failing here previously produced an empty portfolio that
// rendered as "you have no positions".
console.error("[usePortfolio] Failed to batch fetch slabs:", error);
slabAccountsInfo = [];
failedMarkets += markets.length;
}

// Process each slab to find user accounts
Expand Down Expand Up @@ -446,8 +472,17 @@ export function usePortfolio(): PortfolioData {
}
}
}
} catch {
// Skip markets that fail to parse
} catch (error) {
// GH#2414: a market that fails to scan is NOT a market with no
// positions. This path covers the v17 getProgramAccounts owner scan,
// so swallowing it hid real positions while the same positions
// stayed visible on the market page.
console.error(
"[usePortfolio] Market scan failed:",
market.slabAddress.toBase58(),
error,
);
failedMarkets++;
}
}

Expand Down Expand Up @@ -478,9 +513,20 @@ export function usePortfolio(): PortfolioData {
setTotalValue(depositSum + unrealizedPnlSum);
setTotalUnrealizedPnl(unrealizedPnlSum);
setAtRiskCount(riskCount);
// GH#2414: publish completeness alongside the numbers it qualifies,
// so no consumer can read the totals without it.
setIsPartial(failedMarkets > 0);
setFailedMarketCount(failedMarkets);
}
} catch (error) {
// GH#2414: a throw here means market discovery itself failed, so we
// know nothing about this wallet. Previously this was ignored and the
// stale/empty state was left rendering as a completed scan.
console.error("[usePortfolio] Portfolio load failed:", error);
if (!cancelled) {
setIsPartial(true);
setFailedMarketCount((prev) => (prev > 0 ? prev : 1));
}
} catch {
// ignore
} finally {
if (!cancelled) {
setLoading(false);
Expand Down Expand Up @@ -516,5 +562,5 @@ export function usePortfolio(): PortfolioData {
};
}, []);

return { positions, totalPnl, totalDeposited, totalValue, totalUnrealizedPnl, atRiskCount, loading, isRefreshing, refresh };
return { positions, totalPnl, totalDeposited, totalValue, totalUnrealizedPnl, atRiskCount, loading, isRefreshing, isPartial, failedMarketCount, refresh };
}
Loading