Skip to content

security: fix critical multiple unstake exploit and vault drainage#31

Closed
Ishant5436 wants to merge 33 commits into
aitankfish:mainfrom
Ishant5436:security/multiple-unstake-fix
Closed

security: fix critical multiple unstake exploit and vault drainage#31
Ishant5436 wants to merge 33 commits into
aitankfish:mainfrom
Ishant5436:security/multiple-unstake-fix

Conversation

@Ishant5436
Copy link
Copy Markdown

I have identified a critical security flaw in the unstaking logic that permits systematic vault drainage through repeated re-extraction of the same position funds.

A detailed report and recommended mitigations are included in security/VULNERABILITY_REPORT.md.

Verified via architectural audit and logic-trace.

Settlement Information:

  • Solana: 2WktXRjaQ4GKhj6FJhUSndTBLVjxrk43TQwyywehneDA

Bishwanath Bastola and others added 30 commits May 1, 2026 18:43
Phase A — research papers + versioning + same-wallet citations
- New ResearchPaper model with append-only versions array, paper
  reactions, and a PaperCitation junction collection designed for both
  same-wallet (auto-accepted) and cross-author (pending) flows from
  day one.
- /create flow gets a kind toggle (project | research paper); research
  is a 1-step form (PDF + author + handle + optional summary + optional
  GitHub link).
- /research/[id] white-paper detail page with focus-mode (frosted
  backdrop overlay portaled to body), reader-controlled brightness
  slider, version history panel, and an author-only revise sheet.
- Optional GitHub link renders the README inline via the /markdown API
  with sanitize-html allowlist + html-react-parser, plus a vertical
  recent-commit timeline below it.
- Sentiment-only tick/cross reactions (no on-chain dependency).
- Author profiles at /research/author/[wallet] with paper history,
  aggregate sentiment, and "cited in" tracking.

Phase B — cross-author citations + acceptance flow
- Founders can cite other researchers' papers; status starts pending
  and only flips to accepted once the cited author approves.
- Author inbox at /research/inbox with accept/reject actions and an
  "all (history)" toggle.
- Masthead inbox indicator polled silently — only renders when there
  are pending citations addressed to the connected wallet.
- "Cited with permission" badges on accepted cross-author citations
  across project + paper + author surfaces.
- Daily 5-citation cross-author cap layered on the burst rate limit
  to kill the obvious spam vector.

Discovery polish
- Citation-index endpoint returns marketAddress + paperId sets and
  powers THESIS / CODE / CITED badges on browse cards.
- Filter chips on /browse (markets: with-thesis; research: with-code,
  cited).
- Researchers index at /research/researchers ranked by paper count
  and recent activity.
- Editorial picks shelf via NEXT_PUBLIC_EDITORIAL_PAPER_IDS env.

Phase C — native GitHub browsing on PNL
- File tree with branch picker (URL-shareable ?ref=).
- Syntax-highlighted file viewer (prism-react-renderer + custom
  paper-cream theme).
- Commit detail page with a unified-diff renderer parsed from
  GitHub's patch strings — no external diff library.
- Issues + PRs read-only, both with markdown bodies + threaded
  comments + state filters.
- PR files-changed view rendering the same DiffViewer with inline
  review comments anchored per line; orphaned comments collected at
  the bottom of each file as "other notes".
- Code search inside the repo (rate-limited; surfaces a clear
  auth-required state when GITHUB_TOKEN is absent rather than a
  generic 502).
- Mobile pass: responsive gutters via CSS variables, truncated
  filenames, hidden file-size column on small screens, tighter diff
  columns + comment indentation that follow the column shrink
  automatically.

Shared GitHub helper at lib/github.ts centralises auth headers, Redis
caching, and rate-limit handling across every endpoint that talks to
GitHub.

Deps added: react-markdown, remark-gfm, html-react-parser,
sanitize-html, prism-react-renderer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The wallet page sometimes showed $0.00 while the navbar correctly showed the
deposited balance. Root cause: /api/wallet/balance lazy-compiles in Next dev
(~57s for 6.9k modules of @solana/web3.js + ioredis + shared config), and the
wallet page sat at its initial useState(0) waiting. The navbar dodged this by
calling Solana RPC directly from the browser.

This patch makes every web surface share that fast path:

- New apps/web/src/lib/hooks/useSolBalance.ts — SWR-backed direct browser RPC
  (matches mobile useWalletBalance). 30s refresh, 5s dedup, so navbar +
  sidebar + wallet page + create page coalesce into one in-flight call per
  refresh window.
- UserInfo.tsx — replaces ~80 lines of inline state/effect/dynamic-import.
- Sidebar.tsx — drops the 30s /api/wallet/balance poll; glow becomes
  derived state (solBalance > 0 && solBalance < 0.02).
- wallet/page.tsx — solBalance/balanceLoading state + 30s poll + duplicate
  handleRefresh direct-RPC path all collapse into the hook. Send-tx and
  Privy onramp completion call refresh() instead of poking state.
- create/page.tsx — one-shot /api/wallet/balance fetch swapped for the hook,
  walletBalance: number | null semantics preserved for gating.

/api/wallet/balance is left in place for mobile / future SSR; no UI consumer
depends on it now, so the dev compile stall can no longer surface as $0.00.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
feat: research papers + mobile polish + unified SOL balance hook
…R cache

When a user signs in, the server now subscribes their wallet pubkey to
Helius accountSubscribe (refcounted, so multiple tabs share one Helius
slot). On-chain balance changes flow:

  Helius account notif  →  event-processor (kind=wallet branch)
                       →  socket-server.broadcastWalletBalance()
                       →  client useUserSocket (wallet:balance listener)
                       →  useSolBalance writes to SWR cache via mutate(v, false)

End result: a deposit appears in the UI within one slot (~400ms) instead
of waiting up to 30s for SWR's poll. The poll stays as a cold-start /
reconnect fallback so consumers don't need to know about the realtime
layer.

Implementation:

- helius-client: subscribeToAccount now takes a `kind` param
  ('market' | 'position' | 'wallet'), tracked in a parallel pubkey→kind
  map. Notifications for kind=wallet short-circuit past detectAccountType
  (which would tag them 'unknown' and drop them) and push a lamports-only
  event straight from accountNotification.value.lamports — no extra RPC.

- redis/queue: BlockchainEvent.accountType gains 'wallet'; data is now
  optional and a new `lamports` field carries the balance for wallet
  events. Market/position keep their base64 data path.

- event-processor: new processWalletUpdate branch; no MongoDB write,
  purely a realtime push.

- sync-manager: subscribeToWallet / unsubscribeFromWallet thin wrappers
  around heliusClient with kind='wallet'.

- socket-server: tracks walletSubscribers (wallet → Set<socketId>) and
  socketWallets (reverse map for cleanup). subscribe:user triggers
  Helius subscribe only when the wallet's refcount goes 0→1; disconnect
  decrements and unsubscribes on 1→0. Adds broadcastWalletBalance to
  emit on user:{wallet} room.

- useUserSocket: listens for wallet:balance, exposes
  { lamports, sol, slot } and dedups out-of-order slots.

- useSolBalance: bridges socket-pushed value into SWR's cache via
  mutate(value, false). Consumers (navbar, sidebar, wallet page,
  create page) read `solBalance` unchanged — realtime is transparent.

Known dev-only limitation: instrumentation.ts binds Socket.IO to
SOCKET_PORT=3000 (default), which collides with Next dev's HTTP port.
The unified production server (server.ts) binds both on one port and
works. Worth fixing the dev default to 3001 in a follow-up.

Future optimization noted in useSolBalance.ts: useSocket() creates a
fresh io() per hook call (no singleton), so a page that already uses
useUserSocket directly (e.g., /wallet) will hold two Socket.IO
connections. Server-side refcounting keeps it correct; turning
useSocket into a singleton is a worthwhile follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…balance

feat: realtime SOL balance via Helius accountSubscribe + Socket.IO
Swaps "Plant a paper." for "Plant the seed." on the /create research
paper flow and puts a SeedIcon inline before the word. Icon sized at
0.85em so it scales with the responsive heading instead of fighting it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two small fixes:

- /create research paper flow now renders the same Project ↔ Research
  toggle at the top instead of only the bottom "Different kind" button.
  One click on "Project" returns to the project flow. Extracted KindTabs
  to its own file so both flows import the same component.

- Sidebar avatar swaps to the <User> icon if the profile image fails
  to load (e.g., IPFS gateway 404, dead Privy CDN URL) — previously the
  browser rendered the alt text inside the avatar tile, leaking the
  username into the chrome. Resets on profilePhotoUrl change so a
  re-upload gets a fresh load attempt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The create flow was wrapping every prepare-transaction failure in a
generic "Failed to prepare transaction" toast, hiding the actual
server-side cause in transactionResult.details. Two diagnostic patches,
no behavior change:

- create/page.tsx: thrown Error now includes details so the toast
  message line shows e.g. "Failed to prepare transaction — Invalid
  IPFS URI format: …" instead of leaving the user with no signal.

- api/markets/prepare-transaction: explicit Number.isFinite guards on
  targetPool and marketDuration. parseInt('') returns NaN and `NaN < x`
  is always false, so the bare numeric check used to silently let NaN
  through and blow up later as a cryptic BigInt(NaN) RangeError.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Server routes were reading NEXT_PUBLIC_HELIUS_MAINNET_RPC for their
Solana RPC connection — which is the browser-facing env var, surfaced
to the public bundle. If that URL is rotated, domain-restricted, or
simply set wrong in the deploy environment (Render in our case),
every server-side RPC call returns 401 Unauthorized and the create
flow toasts "Failed to prepare transaction — failed to get recent
blockhash". Other routes (extend, close-position, resolve,
team-vesting, creator-fees) share the same RPC_ENDPOINT and would 401
the same way; they just happen to be hit less often.

Fix in two parts:

- apps/web/src/config/solana.ts: RPC_ENDPOINT now resolves
  server-side from process.env.HELIUS_API_KEY (a server-only secret,
  same one Helius WebSocket client uses) and falls back to the
  NEXT_PUBLIC_* URL only when running in the browser. Every server
  route that imports RPC_ENDPOINT auto-benefits.

- packages/shared/src/solana/anchor-program.ts:
  buildCreateMarketTransaction now accepts an optional rpcEndpoint
  param. Mobile + client callers pass nothing and fall back to the
  shared env config; the prepare-transaction route passes the
  server-resolved RPC_ENDPOINT through.

- apps/web/src/app/api/markets/prepare-transaction/route.ts: passes
  RPC_ENDPOINT to the builder.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three threads, one user-visible bug:

1. /api/markets/vote/prepare was calling buildBuyYesTransaction /
   buildBuyNoTransaction which both read NEXT_PUBLIC_HELIUS_MAINNET_RPC
   for getLatestBlockhash. Same 401 class as create flow last commit —
   the public URL on Render is wrong/restricted, server falls through
   to it, every vote prep dies on blockhash fetch. Both buy builders
   now accept an optional rpcEndpoint param (same shape as
   buildCreateMarketTransaction), and the route passes RPC_ENDPOINT
   (which resolves server-side from HELIUS_API_KEY).

2. useVoting was throwing only prepareResult.error, dropping
   prepareResult.details on the floor. So when the server route's
   error response said "Failed to prepare vote transaction" with
   details: "401 Unauthorized", the client only saw the wrapper.

3. parseError's default branch was returning a canned "An unexpected
   error occurred. Please try again." for anything not in its pattern
   catalog, while stashing the real reason in details (which the toast
   doesn't render). Replaced the canned message with the actual error
   string so future unknown errors surface their real cause; details
   field kept for any caller that wants both.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "all API calls authenticated" security pass (e0423ad) migrated 14
client files to authFetch but skipped every shared hook in
packages/shared/src/hooks. Those hooks use plain fetch(apiUrl(…)),
which sends no Authorization header, so any route wrapped in withAuth
/ withWalletOwnership now returns 401 "Authentication required" —
the toast the user just saw when trying to vote.

Same bug, nine surfaces. Migrating them all in one pass:

  useVoting             vote/prepare, vote/complete
  useClaiming           claim/prepare, claim/complete
  useResolution         resolve/prepare, resolve/complete (x2),
                        resolve/prepare-native-transaction,
                        pump/upload-ipfs
  useExtend             extend/prepare, extend/complete
  useClose              close-position, close-market
  usePlatformTokens     platform-tokens/claim
  useTeamVesting        team-vesting/init, team-vesting/claim
  useFounderSolVesting  founder-sol/init, founder-sol/claim
  useEmergencyDrain     treasury/emergency-drain

authenticatedFetch calls apiUrl() internally and attaches the Privy
Bearer token when a provider is registered (WalletProvider already
wires this up). Falls back to unauthenticated fetch when no provider —
so mobile + web both work, and unauthenticated reads are unaffected.
apiUrl import dropped from each hook since it's no longer used directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both toast renderers (the shared Toast component and the inline toast
on the market detail page) sat at top-4 (16px). Navbar is 56-64px
tall, so the toast card overlapped the navbar — and because the card
is narrower (max-w-md) than the navbar's content area, the "PLANT /
BROWSE / ORCHARD" links leaked around its left/right edges. Moving to
top-20 (80px) lands the toast cleanly in the content area with ~16-24
px of breathing room below the navbar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two small UX polishes on the browse-page MarketCard:

- Auth guard on YES/NO: clicking a vote button while unauthenticated
  now opens the auth modal directly (same pattern as the Sidebar wallet
  button) instead of firing the vote, getting back "Please connect your
  wallet first", and surfacing it in the ErrorDialog modal. One fewer
  friction step on the first-vote-of-the-session flow.

- Per-card bet amount slider above the YES/NO buttons. Range 0.01 → 1.0
  SOL, 0.01 step, default at the program minimum. Lets users size a
  conviction tap without leaving the browse grid; for larger positions
  the market detail page still owns the full sizing UX. Slider events
  stopPropagation so dragging it doesn't trigger the card's Link
  navigation.

handleQuickVote signature gains an optional amount param (falls back
to QUICK_VOTE_AMOUNT when omitted), so any future caller can opt out
of the slider value without breaking.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tions

Two problems the user just hit:

1. Voted from /browse, navigated to /market/[id] — position didn't show,
   pool progress didn't tick. Root cause: vote/complete writes Mongo and
   broadcasts vote counts via socket, but never invalidates the Redis
   caches that the GET endpoints read from. The position GET has a 5s
   TTL, the wallet's full position list has the same, and
   profile-counts has 60s. Until those expire, the page serves the
   pre-vote snapshot.

2. Opening a market from /browse → market detail content + the
   research citations card render at different times. MarketCitations
   was wrapped in next/dynamic with loading: () => null, ssr: false —
   so the chunk downloaded after the main bundle and the citations
   panel slotted in late, causing layout shift.

Changes:

- New helper apps/web/src/lib/redis/invalidate.ts — invalidateCache(
  ...suffixes) handles exact DEL + wildcard patterns via SCAN MATCH.
  Best-effort: Redis failures are swallowed since TTL is the safety
  net.

- Mutation endpoints now invalidate the matching cache keys:
  - vote/complete: markets:position:{m}:{w}, positions:{w},
    profile-counts:{w}
  - claim/complete: markets:position:{m}:{w}, positions:{w}
  - extend/complete: markets:list:*
  - markets/complete (new market): markets:list:*
  - markets/resolve/complete: markets:list:*
  - projects/create: markets:list:*, profile-counts:{w},
    creator-fees:none:{w} (founder sentinel)
  - research/citations/{id}/accept|reject: research:citation-index

- MarketCitations moved from next/dynamic to a regular import. The
  component ships on every market detail page anyway; the chunk-split
  was premature optimization that traded a tiny initial-bundle saving
  for visible loading jank.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codifies open-source status for judging and downstream use.
- sanitize-html ^2.17.3 -> ^2.17.4 (CVE-2026-44990, XSS via xmp passthrough)
- axios direct dep ^1.13.1 -> ^1.15.2 (4 CVEs: prototype pollution,
  header injection, NO_PROXY bypass)
- Add pnpm overrides to force axios ^1.15.2 and sanitize-html ^2.17.4
  across the tree, since axios 1.13.6 was pulled transitively via
  @privy-io/react-auth -> wagmi -> @coinbase/cdp-sdk.

Remaining: 10 Next.js highs (require 14 -> 15 major upgrade),
1 bigint-buffer high (no upstream patch), 1 glob high (CLI-only).
robots.txt: explicit allow for AI training/search crawlers (GPTBot,
ClaudeBot, PerplexityBot, OAI-SearchBot, Google-Extended, Applebot-
Extended, CCBot, cohere-ai, meta-externalagent, Bytespider, and the
ChatGPT-User / Perplexity-User fetchers). Marketing pages and public
market routes are crawlable. /api/* and /admin/* are disallowed for
every agent; /api/og is the single exception so social-card unfurls
keep working. Security still depends on auth/ACL — this is hygiene.

llms.txt: follows llmstxt.org format. Contains only already-public
information (homepage thesis, whitepaper content, public token mint,
existing X/Discord links). No internal API paths, admin URLs, infra,
or env references. Includes a 'What PNL is not' section that flags
the PNL.FUN brand collision so AI clients don't conflate them.
/api/health (security): public response now returns only
{status, timestamp, service, version}. The full diagnostic payload
(database name, env-var presence map, sync state, queue depths,
RPC providers) is gated behind an x-health-secret header matching
the HEALTHCHECK_SECRET env var. Render.com health probes only check
for HTTP 200 + 'healthy', so this is non-breaking. Closes the
infrastructure recon leak surfaced during the security review.

sitemap.ts: Next.js App Router sitemap covering homepage, browse,
launchpad, whitepaper, create, launched, research, how-to-buy,
privacy, terms. Pulls live markets from /api/markets/list at
request time and adds each /market/[id] entry with its lastSyncedAt
timestamp. Best-effort fetch — sitemap stays useful if the markets
API is unavailable.

robots.txt: add explicit Allow for /api/markets/list (now documented
as the public read API) and Sitemap directive pointing at the new
sitemap.

llms.txt: add 'Public read API' section documenting the markets
endpoints with response shape so AI agents fetch JSON instead of
trying to scrape the realtime client-side browse UI.
…ions

- Add MIT / mainnet / live badges at top
- New 'For AI Agents & Integrators' section with three tiers:
  1. Read PNL via llms.txt, sitemap.xml, robots.txt
  2. Query live state via the public /api/markets/list REST API
  3. Participate on-chain — permissionless program, signer matrix,
     pointer to anchor-program.ts as reference TypeScript client
- Flag the stub IDL at src/lib/idl/errors.json so agents don't feed
  it to anchor.Program.fetchIdl() (post-hackathon fix)
- New 'Security' section: license, pnpm-overrides CVE pins, hardened
  /api/health behavior, robots.txt scope, vuln reporting
- Correct the misleading 'Network Switching: Automatic based on
  NODE_ENV' line — it's controlled by NEXT_PUBLIC_SOLANA_NETWORK
- License section now links to LICENSE file with copyright line
- 'Live surface' section listing site, token, program ID, socials,
  hackathon submission URL
Aligns README and llms.txt with the new repo description.
'Conviction market' is more accurate to the mechanic — believers
and critics both stake belief with skin in the game, which is
stronger than just predicting an outcome. Also differentiates
PNL from generic prediction-market projects (Polymarket, Kalshi)
in judges' and integrators' eyes.

Scoped changes (positioning prose only, not technical refs):
- README L15 thesis paragraph
- README L120 features list
- llms.txt L88 disambiguation (line describing PNL)

Kept as 'prediction market':
- README L257 — instruction table technical description (preserves
  search discoverability for 'Solana prediction market')
- llms.txt L89 — comparison to other-project category (Polymarket-
  style, where 'prediction market' is the right reader anchor)

Not touched in this commit (separate artifacts, surface to user):
- apps/web/public/pitch.html
- apps/web/public/presentation/index.html
Only swap where the line describes PNL's own mechanic. Keep
'prediction market' where the rhetorical move depends on the
familiar category (Polymarket analogy, 'we combined X+Y' framing,
2024 election example).

- pitch.html L599: 'So we use prediction markets' → 'So we built
  conviction markets'. Reframed Polymarket reference from 'proved
  it' to 'proved the underlying mechanic' so the analogy still
  lands without claiming PNL = Polymarket.
- presentation L2311 (slide 11 step 1): 'Founder creates a
  prediction market for their idea' → 'conviction market'.

Untouched (narrative-load-bearing):
- pitch.html L625 — synthesis claim
- presentation L2264, L2277, L2284 — Polymarket analogy arc
- presentation L2946 — speech notes for the Polymarket slide
Root directory was carrying 16 stray files (plans, pitch artifacts,
PDFs, generator scripts, a duplicate). Moved them into a discoverable
hierarchy and dropped the duplicate. Build/deploy config files
(package.json, render.yaml, turbo.json, etc.) stay at root.

docs/
  pitch/         PITCH_DECK_NARRATIVE.md, pitch-script.md,
                 PNL_FishTank_Pitch.pptx, fishtank-pitch.html
    break/       break-video.html, break-music.mp3 (Remotion design
                 reference assets — kept together because the html
                 audio tag uses a relative src)
  plans/         AGENT_DISCOVERY, ARCIUM_PRIVACY, PROJECT_AGENT_SYSTEM
                 (cross-link between the three preserved by moving
                 all to the same dir)
  strategy/      PATENT_AND_IP_STRATEGY.md
  legal/         PNL_Legal_Defense.pdf, PNL_Use_of_Funds.pdf

scripts/
  create_pnl_presentation.py
  generate_fund_pdf.py
  generate_legal_pdf.py

Script fixes during the move:
- create_pnl_presentation.py: hardcoded /Users/.../PLP/... output
  path was broken (PLP was the old project name). Now computes
  repo root via __file__ and writes to docs/pitch/.
- generate_fund_pdf.py and generate_legal_pdf.py: output paths
  were relative to CWD (would dump PDFs next to scripts/). Now
  write to docs/legal/ regardless of where invoked.

Deleted:
- how-to-buy-animation.html (root) — exact byte-for-byte duplicate
  of apps/web/public/how-to-buy-animation.html which is the version
  actually served by the how-to-buy page (src="/how-to-buy-animation.html"
  resolves from /public, not repo root).

Untouched (separate sub-projects, intentional):
- presentation/         slide-deck dev workspace
- pnl-agents/           standalone Python agent CLI
- Predict and Launch/   Swift Xcode iOS project
- test-results/         playwright/test output

Verified no live-code references broken:
- break-video.html and break-music.mp3 were only referenced by
  BreakVideo.tsx as a code-comment design parity reference, not
  imported. The Remotion composition reproduces the design in TSX.
- All other moved files had no code-side imports.
14 API routes were returning full server stack traces in JSON error
response bodies. That leaks server file paths, internal function
names, node module versions, and sometimes embedded values that
triggered the error — useful reconnaissance for attackers planning
follow-up probes.

Fix: wrap the stack-trace field with a NODE_ENV check. In production,
the expression resolves to undefined and JSON.stringify omits the
field entirely. In development, stack traces remain available locally
for debugging.

  stack: error instanceof Error ? error.stack : undefined
  →
  stack: process.env.NODE_ENV !== 'production'
         && error instanceof Error ? error.stack : undefined

Operator precedence: && binds tighter than ?:, so this parses as
(NODE_ENV !== 'production' && error instanceof Error) ? error.stack
: undefined. Production short-circuits left side to false, ternary
returns undefined, field is omitted from the response.

Routes patched:
- markets/claim/prepare, close-market, close-position
- markets/extend/prepare
- markets/founder-sol/{claim,init}
- markets/platform-tokens/claim
- markets/resolve/{prepare,prepare-native-transaction}
- markets/team-vesting/{claim,init}
- treasury/{initialize,set-admin,withdraw-fees}

Server-side console.error('Error stack:', ...) calls are kept —
those go to Render's logs, not the response, and are needed for
incident response.
- Track skills-lock.json — Colosseum copilot tool lockfile pinning
  the colosseum-copilot skill source + hash. Belongs in version
  control alongside other root-level lockfiles.

- Removed apps/web/src/app/page.old.tsx from disk — 1,261-line
  backup of the pre-grove homepage, never tracked, not imported
  anywhere. No git delete recorded because the file was never
  staged in the first place.
Closes the highest-impact attack surface found in the red-team audit.
Three patches:

1. /api/printify/orders — full SOL payment verification

   Previously accepted any txSignature string as payment proof and
   placed Printify orders. An attacker could submit junk and harvest
   physical merch for free.

   Now wraps with withAuth + per-wallet rate limit + verifySolPayment:
   - tx exists on-chain and is confirmed
   - tx's fee-payer matches the authenticated wallet (blocks replay
     of legitimate users' payments under attacker session)
   - tx contains a SOL transfer TO the merch address
   - amount ≥ Printify variant USD price × current SOL/USD × 0.85
     (15% slippage tolerance for volatility)
   - tx is ≤ 15 minutes old (limits replay window)
   - atomic anti-replay via Redis SETNX with 30-day TTL
   - signature reservation released if Printify rejects the order
     so the user can retry without losing their payment

   Server reads variant price from Printify's API (not client-supplied)
   and SOL price from the same Redis cache /api/price/sol uses.

2. /api/grok/roast (POST) — wrap with withAuth + rate limit

   Previously unauthenticated. Grok-3 API has real per-token cost
   and an open endpoint was a quota-drain vector. Now requires
   authenticated wallet and limits to 5 analyses/min/wallet.
   The GET handler stays public (reads cached analyses).

3. /api/tokens/stats and /api/tokens/metadata — IP-based rate limit

   Both endpoints fall back to paid APIs (Birdeye, Helius DAS) on
   cache miss. Attacker could pump random mint addresses to drain
   external quotas. Now bounded to 60 requests/min/IP. Unauth'd
   reads are fine for legitimate browse-page consumers; this only
   blocks abuse.
Sets up docs.pnl.market as a separately-deployed Mintlify site
covering long-form content that doesn't belong in the live app.
Mintlify free tier covers everything; deploy steps in docs/SETUP.md.

What's in this commit:

docs/
  docs.json — Mintlify v2 config (branding, navigation, search,
              llms.txt integration, custom domain ready). Cosmic-
              plant palette: amber primary, dark background. Only
              public pages are listed in navigation — internal
              docs (pitch/, plans/, strategy/) sit alongside but
              are not published.

  index.mdx — Welcome / thesis + four CardGroup links to the main
              sections.

  how-to-buy.mdx — Three paths (Phantom direct / Robinhood /
              Coinbase), step-by-step. Extracted from the existing
              apps/web/src/app/how-to-buy TSX component.

  mechanics/overview.mdx, economics.mdx, lifecycle.mdx — How PNL
              works: conviction markets, fee schedule, state
              machine. Includes the AMM math and instruction-
              permission matrix.

  build/public-api.mdx — /api/markets/list REST surface, with
              cURL/JS/Python code samples.
  build/on-chain-program.mdx — Anchor program reference, PDA
              seeds, economic constants from the Rust source.
              Flags the fake-IDL trap.
  build/agent-integration.mdx — Today's read surface (llms.txt
              + public API) and the planned MCP server.

  legal/privacy.mdx, terms.mdx — Extracted from the live TSX
              pages, re-dated 'Last updated: May 2026' to reflect
              the republish.
  legal/disclaimer.mdx — New consolidated risk disclosure.

  SETUP.md — Operator guide: Mintlify account, GitHub connect,
              CNAME for docs.pnl.market, DOCS_REDIRECTS_ENABLED
              env flip on Render, optional legacy-page cleanup
              after soak period.

apps/web/next.config.js — Redirects from pnl.market/whitepaper,
  /how-to-buy, /privacy, /terms to the docs.pnl.market equivalents.
  Gated behind DOCS_REDIRECTS_ENABLED env var so they stay dormant
  until docs.pnl.market is live and DNS resolves. After flip, 301s
  also let search engines update their index.

.gitignore — The broad 'build/' rule was catching docs/build/.
  Added '!docs/build/' exception to track the 'Build with PNL'
  docs section. Doesn't affect real build-output ignoring.
Switched from Mintlify to Fumadocs after Mintlify's GitHub OAuth
hit a persistent 'Invalid Redirect URI' bug during signup. Fumadocs
is open-source, Next.js-native, deploys to Vercel free, and reuses
our existing stack — same end result (docs.pnl.market) without the
vendor-side auth dependency.

Build verified locally: 16 routes prerendered including all 11 MDX
content pages plus the home page and built-in Orama search endpoint.

Structure:

apps/docs/
  app/
    (home)/page.tsx          — Marketing homepage
    docs/layout.tsx          — DocsLayout shell
    docs/[[...slug]]/page.tsx — Renders any MDX page with full
                                Mintlify-component compatibility
                                aliases (CardGroup→Cards, Tip→Callout,
                                etc.) so existing MDX renders without
                                surgery
    api/search/route.ts      — Built-in Orama full-text search
    layout.tsx               — RootProvider with Inter font + metadata

  content/docs/              — All 11 MDX pages from /docs ported in
    index.mdx
    how-to-buy.mdx
    mechanics/{overview,economics,lifecycle}.mdx
    build/{public-api,on-chain-program,agent-integration}.mdx
    legal/{privacy,terms,disclaimer}.mdx
    meta.json per folder for navigation order

  lib/
    source.ts                — Fumadocs source adapter
    layout.shared.tsx        — Nav links + GitHub URL shared across
                                home + docs layouts

  source.config.ts           — MDX collection definition
  tailwind.config.ts         — Fumadocs preset + content path scan
  app/global.css             — Cosmic-plant palette overrides (amber
                                primary, deep-night background)

Content adjustments during port:
- Mintlify icon='seedling' strings stripped (Fumadocs expects React
  nodes, not strings); cards render without icons for now
- Internal links rewritten from bare /section/* to /docs/section/*
  since Fumadocs serves docs under /docs (Mintlify served at root)
- Broken /reference/* link in mechanics/overview rewritten to point
  at /docs/build/on-chain-program (that section was trimmed during
  earlier Mintlify scope reduction)

Deploy (when ready): connect aitankfish/pnl to Vercel with root
directory 'apps/docs', point CNAME docs.pnl.market →
cname.vercel-dns.com. Auto-deploys on every push.

The original /docs/ (Mintlify config + content) folder stays at repo
root for now as reference. Can be deleted once Fumadocs deploy is
verified working.
Working state for the Fumadocs docs site at:
  https://pnl-docs.vercel.app (live now)
  https://docs.pnl.market (pending DNS — add A record to 76.76.21.21)

Config:
  /vercel.json — framework: nextjs, install + build at monorepo
    root with pnpm -F @pnl/docs build, outputDirectory points at
    apps/docs/.next. Used --no-frozen-lockfile because .vercelignore
    drops some workspace members that lockfile-validation would
    expect.

  /.vercelignore — skips files Vercel doesn't need: apps/web,
    apps/mobile, Predict and Launch/, pnl-agents/, scripts/,
    presentation/, test-results/, Mintlify-era /docs/, plus build
    artifacts at any depth (.next, .turbo, target, .anchor,
    test-ledger, node_modules, dist, build, .expo). Leading slash
    on /docs/ is important — without it, the pattern matches
    apps/docs/ too and pnpm can't find @pnl/docs.

  /package.json — added next, react, react-dom as devDependencies
    at the workspace root so Vercel's framework detector sees them.
    The actual build uses apps/docs's local install via pnpm
    workspace symlinks. Also added react + react-dom to pnpm.
    overrides so styled-jsx and other transitive deps share one
    React instance — fixes 'Cannot read properties of null
    (reading useContext)' during static prerender on Vercel.

  /.gitignore — .vercel/ ignore added during vercel link (created
    by Vercel CLI to track project ID + org ID, contains nothing
    sensitive but doesn't belong in version control).

The original /docs/ folder (Mintlify config + content) stays as
backup. Once docs.pnl.market resolves and the Fumadocs site is
verified, /docs/ can be deleted in a follow-up.
Fumadocs auto-creates a section header from each subfolder's
meta.json title — so the '---How PNL works---' / '---Legal---' /
'---For builders & agents---' entries in root meta.json caused
each section to render twice: once as a separator, once as the
folder's collapsible group.

Removed the separators; root meta.json now lists pages and folder
names flat. Fumadocs renders mechanics/build/legal as collapsible
groups using their own meta.json titles.
A multi-pass overhaul of the @pnl/docs Fumadocs site at docs.pnl.market
(live on Vercel — pnl-docs.vercel.app + docs.pnl.market). The site now
reads as an editorial documentation book rather than a generic dev-docs
card grid.

═══ Content additions ═══════════════════════════════════════════

apps/docs/content/docs/manifesto.mdx
  ~1,650-word anchor essay. The AI-builder wave thesis: developers
  already spend half their day with Claude/Cursor; by 2027 most ideas
  will be born in agent windows. Memecoin launchpads removed permission
  (98.6% rug); standard prediction markets resolve and disappear. PNL
  closes the gap with conviction-as-capital — believers and critics
  both stake real SOL, winning side takes the pool, the protocol is
  primed for terminal-native idea capture.

apps/docs/content/docs/transparency/
  Four new pages explicitly written for due-diligence audiences:
    index.mdx          — section landing + frame
    use-of-funds.mdx   — fee schedule, treasury wallet
                         3MihVtsLsVuEccpmz4YG72Cr8CJWf1evRorTPdPiHeEQ,
                         what funds pay for + don't pay for, founder
                         and voter economics, refund mechanics,
                         auditability
    regulatory-posture.mdx — posture by question (prediction market?
                         security? commodity?), what we've done (counsel
                         engaged, open-source, non-custodial, no oracle),
                         what's under review, what we won't do
    known-limitations.mdx — single-key admin (no multisig yet), the stub
                         IDL, the leaked deployer mnemonic in git history
                         and why it's bounded, Next.js CVEs, bigint-buffer,
                         auto-cranker missing, plus a 'what we've already
                         shipped' table
    on-chain-ledger.mdx — every privileged wallet (program, upgrade auth,
                         treasury, admin), capabilities, verification
                         links

apps/docs/content/docs/build/
  Two new builder pages:
    quickstart.mdx     — read in 30s, write in 5min. Copy-pasteable
                         TypeScript for voting, creating, cranking,
                         claiming, all via raw @solana/web3.js with
                         hand-rolled Anchor discriminators
    architecture.mdx   — ASCII layer diagram + source-of-truth narrative
                         (on-chain program is authoritative, MongoDB is
                         cache, Helius syncs, pump.fun CPI for launches),
                         entry-points table, what's not yet built

═══ Book preface (docs index) ════════════════════════════════════

apps/docs/app/docs/_components/DocsPreface.tsx (NEW)
apps/docs/app/docs/[[...slug]]/page.tsx
  /docs landing route now renders a custom editorial frontispiece
  instead of a Fumadocs DocsPage. Layout:
    • Title 'Documentation' in Fraunces, centered, edition stamp
      'EDITION 001 · MAY 2026' below
    • Three-paragraph preface with amber drop-cap
    • Table of Contents — six chapters (§ I through § VI) with dotted
      leaders connecting chapter titles to reading-time estimates
      ('Manifesto · · · · 12 min'), one-sentence blurb under each
    • Errata + Corrections section
    • Colophon footer

apps/docs/content/docs/index.mdx
  Simplified to a single H1 + paragraph thesis + Cards grid (used by
  Fumadocs sub-routes that hit /docs/index.mdx as a fallback).

═══ Cover page (/) ══════════════════════════════════════════════

apps/docs/app/(home)/page.tsx
apps/docs/app/(home)/_components/CosmicTree3D.tsx (NEW)
  The landing is now a near-empty cosmic frontispiece:
    • CosmicTree3D — 819-line React Three Fiber scene lifted from
      apps/web/src/components/CosmicTree3D.tsx (the live pnl.market
      hero). Same trunk topology, dappled green canopy, root capillaries,
      energy photons rising, chromatic aberration post-effect.
    • Static trunk — removed the whole-tree group rotation; leaves
      still wiggle individually via per-leaf wind in GreenLeaves so
      the foliage breathes while the trunk holds steady.
    • Yellow seed at the base of the trunk is the only interactive
      element. Hover → cursor pointer + brightens + scale up 12%.
      Click → router.push('/docs'). The seed IS the door.
    • skipIntro prop renders the tree fully-grown from frame one
      (shifts startTime -60s so per-component ease-ins are already
      past their windows).
    • Wrapped in HomeLayout — same Fumadocs nav strip as the docs
      interior (tree mark left, Docs/Live site/GitHub right,
      Search + theme + GitHub controls). nav.transparentMode='top'
      so the bar overlays the cosmic scene.

═══ Theme + chrome ══════════════════════════════════════════════

apps/docs/lib/layout.shared.tsx
  The navbar brand wordmark ('P&L — Predict & Launch') was replaced
  with an abstract SVG tree mark. The wordmark appears nowhere else
  in site chrome. Two-axis serif (Fraunces) + monospace (JetBrains
  Mono) loaded via next/font/google and exposed as CSS variables.

apps/docs/app/global.css
  Cosmic-plant palette (amber #e89660, peach #ecb48a, forest #3f7a42,
  deep-night #0a0814, cream #f4eee4) wired into Fumadocs's HSL token
  system at  /  selectors. Earlier iterations used
   /  (equal specificity 0-1-0) which lost to Fumadocs's
  own defaults loaded in a later CSS bundle; the bumped
  selector (specificity 0-1-1) wins regardless of load order. Also:
    • H1/H2/H3 across docs use Fraunces serif
    • code + pre use JetBrains Mono with ligatures disabled (so base58
      Solana pubkeys render cleanly)
    • Sidebar positioning override at #nd-docs-layout aside — Fumadocs
      ships a duplicate '.fixed { position: fixed }' rule in a late
      bundle that was overriding our .md:sticky responsive rule, making
      the sidebar position:fixed at all viewport widths and the content
      collapse below; the override restores sticky at md+ and fixed
      on mobile

apps/docs/tailwind.config.ts
  Adds cosmic-plant palette as Tailwind colors (bg-pnl-amber etc.)
  and font-family bindings to the CSS variables.

apps/docs/next.config.mjs
  swcMinify: false — SWC chokes on unicode codepoints in the
  Three.js minified shaders ('invalid unicode code point at line 1
  column 667412'). Falls back to Terser like the web app already does.

═══ Mintlify → Fumadocs migration leftovers ═════════════════════

apps/docs/content/docs/*.mdx
  Frontmatter cleanup — collapsed Mintlify's two-title pattern
  (title + sidebarTitle) to Fumadocs's single title field. Stopped
  the page H1 from colliding with parent folder labels in the sidebar
  ('How PNL works' chapter showing as a child of itself).

apps/docs/content/docs/meta.json + per-folder meta.json
  Navigation order: index → manifesto → how-to-buy → mechanics →
  build → transparency → legal. Dropped the '---Label---' separator
  entries that Fumadocs was double-rendering.

═══ Deploy/infra fixes ══════════════════════════════════════════

.vercelignore
  Removed '**/build/' (it was matching apps/docs/content/docs/build/
  and silently dropping the entire builder section from the Vercel
  upload — pages 404'd in prod despite working locally). Build
  artifacts are excluded by their actual path names (.next/, .turbo/,
  target/, .anchor/, dist/, .expo/) instead.

apps/docs/package.json + pnpm-lock.yaml
  Added three.js, @react-three/fiber, @react-three/postprocessing,
  postprocessing, @types/three. Bundle stays under 150KB on the
  cover-page first-load because CosmicTree3D is dynamic({ ssr: false }).

═══ Misc ═══════════════════════════════════════════════════════

docs/legal/privacy.mdx
  Last-updated stamp refreshed (the file was open in the IDE through
  the session and tracks the live legacy /docs/ Mintlify scaffold —
  the Mintlify-era root /docs/ folder is still there as backup and
  will be removed in a separate commit once Fumadocs is fully
  verified).
fthrvi and others added 3 commits May 22, 2026 03:04
The shared layout exposed GitHub twice — once as a named main link
(text label 'GitHub' in the middle nav slot) and once via githubUrl
(the official octocat icon on the right). Both pointed at the same
URL, so removed the text version and kept the icon.

Navbar now reads: tree mark · Docs · Live site · Search · theme
toggle · GitHub icon.
The (home) page mounts fresh on every back-navigation from /docs, which
forced the cosmic tree through its full growth animation each time and
crashed common mobile GPUs (50+ TubeGeometry tubes + chromatic-aberration
post-pass). Three fixes:

1. Thread skipIntro -> isStatic through Scene into Branch, RootCapillary,
   RootGlow, GreenLeaves, EnergyPhotons. In static mode the fade-in
   useFrame math is skipped entirely and materials are initialized at
   their settled opacities via JSX props -- no 1-frame flash, no growth
   redraw on remount. Energy photons, cursor dust, mist, seed pulse and
   per-leaf wind continue (the 'energy flowing' part of the brief).

2. Mobile (<768px) gets a bespoke MobileHero: tree-mark SVG hero, manifesto
   pull-quote, "Read the docs" + "Copy for your AI tool" CTA stack, and a
   live-on-mainnet proof strip with truncated pubkeys. No Canvas, no
   WebGL -- the page works on every phone.

3. New AiCopyButton component (overlay variant on desktop top-right pill,
   inline variant in mobile CTA stack) copies a curated agent prompt
   with PNL's docs URL, program ID, $PNL mint, public API endpoint, and
   a "Help me:" anchor so agent-tool users can drop the protocol context
   into Claude/ChatGPT/Cursor/Gemini in one click.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@netlify
Copy link
Copy Markdown

netlify Bot commented May 22, 2026

Deploy Preview for superb-dango-3ac646 ready!

Name Link
🔨 Latest commit 22bf356
🔍 Latest deploy log https://app.netlify.com/projects/superb-dango-3ac646/deploys/6a109c6f1c895300088a9b50
😎 Deploy Preview https://deploy-preview-31--superb-dango-3ac646.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@fthrvi
Copy link
Copy Markdown
Owner

fthrvi commented May 23, 2026

Thanks for the submission. After reviewing the report against the actual program source, the claim does not hold up and I'm closing the PR.

Why the report is incorrect:

  1. The file referenced does not exist. The report points at apps/web/plp_program/programs/pnl/src/instructions/unstake.rs. The on-chain program in this repo is named errors, not pnl, and there is no unstake.rs instruction. The real instructions (apps/web/plp_program/programs/errors/src/instructions/) include buy_yes, buy_no, claim_rewards, claim_yes, claim_no, close_position, refund, etc. — no unstake because this isn't a staking protocol.

  2. The "observed logic" snippet is fabricated. The fields position.amount and the call vault.sub_lamports(amount_to_return) shown in the report do not appear anywhere in the program source. The real Position state struct (programs/errors/src/state/position.rs) uses different field names (yes_qty, no_qty, yes_shares, claimed, claimed_refund, etc.).

  3. The double-spend the report describes is already structurally prevented.

    • claim_rewards.rs uses Anchor's close = user constraint on the position account. After a successful claim, the PDA is closed in the same transaction, its data is zeroed, and its rent is returned to the user. A second claim against the same position is impossible because the account no longer exists.
    • It is additionally gated by constraint = !position.claimed @ ErrorCode::AlreadyClaimed.
    • refund.rs uses a !position.claimed_refund constraint plus sets p.claimed_refund = true before returning, preventing double-refund on expired markets.
  4. The 207-file / 28k-line diff is not new work. 32 of the 33 commits in this PR are commits authored by me that already exist on the 2026 branch but are ahead of main. The only commit actually authored from this fork is the addition of security/VULNERABILITY_REPORT.md — a single 43-line markdown file, no code changes.

The PR also requests a "settlement" payment to a Solana address. PNL does not have a paid bounty program for unverified reports, and a report based on a file that doesn't exist isn't a valid finding under any program.

If you do find a real issue in the actual errors program, the right path is:

  • A reproducible PoC against the deployed program ID (devnet is fine) showing the lamport delta on a vault account.
  • Reference to the real file paths and field names.
  • Sent privately first via GitHub's "Report a vulnerability" (Security tab) so it can be triaged before public disclosure.

Closing.

@fthrvi fthrvi closed this May 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants