Skip to content

fix(tests): retarget 20 tests left behind by the wallet bundle-split refactor - #2451

Merged
dcccrypto merged 52 commits into
playgroundfrom
fix/app-tests-stale-wallet-refactor
Aug 1, 2026
Merged

fix(tests): retarget 20 tests left behind by the wallet bundle-split refactor#2451
dcccrypto merged 52 commits into
playgroundfrom
fix/app-tests-stale-wallet-refactor

Conversation

@dcccrypto

@dcccrypto dcccrypto commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Follow-up to #2450 / #2447. #2450 made the app suite actually run in CI, surfacing 65 real failures. This fixes 20 of them.

The common cause

All five files point at modules whose logic moved during the wallet bundle-split refactor. The tests kept asserting against the old location, so they failed — and the code that actually took over the behaviour was left with no coverage at all. Making them green was the smaller half; restoring the lost coverage was the point.

File Was Now
useWallet.test.ts mocked Privy, expected useWalletCompat() to derive wallet state hook is a pure WalletApiContext read — tests the context read, the read-only default, and that it imports no wallet SDK (the invariant the refactor exists for)
PrivyProviderClient.test.tsx 1 config test; Privy solana mock missing the bridge's new hooks +11 tests over the Privy → WalletApi derivation: connection state, active-wallet selection, signMessage binding, disconnect, referential stability
ConnectButton.test.tsx rendered the dynamic(ssr:false) shell, which never resolves in jsdom retargeted at ConnectButtonPrivyInner; all 3 cases were asserting against the "Loading wallet" placeholder
useStuckSlabs.test.ts drove a percolator-pending-slab-keypair key the hook no longer reads rewritten against lib/inFlightMarket (per-slab keys + wallet gate); adds wallet-gate and admin-address-filter coverage
SlabProvider-allowlist.test.tsx all cases shared one slab address across a module-level cache fresh address per test; mockReset so the WS test's impl stops leaking forward

The PrivyWalletApiBridge coverage matters most here: that path builds and signs every transaction in the app and had zero tests after the logic moved out of the hook.

One product change

SlabProvider's phishing-guard reject path spread the previous state:

setState((s) => ({ ...s, loading: false, error: "…not owned by a recognized…", programId: null }));

The seed cache (getSeedSlab) can already have parsed and published config/engine/accounts for that address before the owner is known, so parsed state stayed visible on an account the provider was actively refusing to trust. Nulling programId alone isn't enough — downstream UI reads config/engine too. Now resets to defaults, keeping only slabAddress.

Evidence

  • Local: 95 → 75 failures, 2702 → 2714 tests
  • npx tsc --noEmit clean
  • No file touched here is still failing; the remaining 23 files are separate clusters (waitlist/bugs routes absent on playground, stake-pool, chart-drawings, priceStore)

How to test

cd app && npx vitest run \
  __tests__/hooks/useWallet.test.ts \
  __tests__/hooks/useStuckSlabs.test.ts \
  __tests__/components/PrivyProviderClient.test.tsx \
  __tests__/components/ConnectButton.test.tsx \
  __tests__/providers/SlabProvider-allowlist.test.tsx

cc @dcccrypto — the SlabProvider change is in a security guard, so it wants a security look before merge.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Cleared stale slab configuration and account data when a slab is not owned by a recognized program.
    • Improved wallet and stuck-market recovery behavior, including wallet selection, signing, disconnect handling, and market clearing.
    • Added safer handling for missing accounts, malformed recovery data, RPC failures, and small slab buffers.
  • Tests

    • Expanded coverage for wallet bridging, slab recovery, provider state resets, and phishing-guard behavior.

…refactor

PR #2450 made the app suite actually run in CI, surfacing 65 failures. This
fixes 20 of them. They share one cause: the bundle-split refactor moved logic
out of the modules these tests still pointed at, so the tests asserted against
code that no longer had the behaviour — and the code that DID have it was left
with no coverage at all.

- useWallet.test.ts: useWalletCompat is now a pure WalletApiContext read; the
  Privy derivation moved to PrivyWalletApiBridge. Tests now cover the hook for
  what it is (context read + read-only default + no wallet-SDK import, which is
  the invariant the refactor exists to protect).
- PrivyProviderClient.test.tsx: picks up the Privy -> WalletApi derivation the
  hook test dropped — connection state, active-wallet selection, signMessage
  binding, disconnect, and referential stability. This path builds and signs
  every transaction in the app and had zero tests. Also completes the
  @privy-io/react-auth/solana mock, which the bridge's new hooks broke.
- ConnectButton.test.tsx: retargeted at ConnectButtonPrivyInner. The shell is a
  dynamic(ssr:false) import that never resolves in jsdom, so all three cases
  were asserting against the "Loading wallet" placeholder.
- useStuckSlabs.test.ts: rewritten against the current lib/inFlightMarket
  contract (per-slab keys + wallet gate). The old suite drove a
  `percolator-pending-slab-keypair` key the hook no longer reads. Adds coverage
  for the wallet gate and the admin-address filter — the guard that stops
  another wallet's in-flight market surfacing in the recovery banner.
- SlabProvider-allowlist.test.tsx: the cases shared one slab address across a
  module-level cache (lib/slabCache), so each asserted against the previous
  one's residue. Fresh address per test; mockReset so the WS test's
  implementation stops leaking forward.

One product change, in the phishing guard's reject path: it spread the previous
state, so config/engine parsed from the seed cache stayed visible on an account
it was actively refusing to trust. Nulling programId alone was not enough —
downstream UI reads config/engine too. Now resets to defaults.

Local: 95 -> 75 failures, 2702 -> 2714 tests. tsc --noEmit clean. No file
touched here is still failing; the remaining 23 files are separate clusters.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
percolator-launch Ready Ready Preview, Comment Jul 28, 2026 10:28pm
percolator-mainnet Ready Ready Preview, Comment Jul 28, 2026 10:28pm
percolator-playground Ready Ready Preview, Comment Jul 28, 2026 10:28pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5b770239-1ee0-42c6-8a56-db8778cdea5b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The changes expand wallet bridge and compatibility tests, update in-flight slab recovery coverage to the current storage and wallet contracts, reset stale slab provider state for unknown owners, and isolate allowlist test state.

Changes

Wallet bridge validation

Layer / File(s) Summary
Privy WalletApi bridge coverage
app/__tests__/components/PrivyProviderClient.test.tsx
Tests cover WalletApi configuration, connection state, active wallet selection, signing, disconnect, and referential stability through mocked Privy and Solana hooks.
Wallet compatibility consumers
app/__tests__/hooks/useWallet.test.ts, app/__tests__/components/ConnectButton.test.tsx
Tests validate read-only and context-provided wallet APIs, connection reuse, bundle-split imports, and the Privy-backed ConnectButton implementation.

Slab recovery and ownership state

Layer / File(s) Summary
In-flight market recovery coverage
app/__tests__/hooks/useStuckSlabs.test.ts
Tests use persisted in-flight markets and wallet gating to validate slab classification, malformed entries, RPC failures, ordering, reconstruction, and clearing.
Unknown-owner state reset
app/components/providers/SlabProvider.tsx, app/__tests__/providers/SlabProvider-allowlist.test.tsx
Unknown program owners reset parsed slab fields while preserving the address; allowlist tests generate isolated addresses and reset WebSocket mocks between cases.

Estimated code review effort: 4 (Complex) | ~45 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: retargeting tests after the wallet bundle-split refactor.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/app-tests-stale-wallet-refactor

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@dcccrypto

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
app/__tests__/hooks/useStuckSlabs.test.ts (1)

254-264: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Test does not validate the behavior its name claims.

The title/comment say the hook "keeps the last-good list" on a transient RPC error, but no successful load is seeded first, so stuckSlabs is vacuously [] on initial mount regardless of the catch behavior. To actually exercise the "don't blank the banner" path, resolve getAccountInfo successfully first, wait for a populated list, then make a subsequent refresh() reject and assert the list is preserved.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/__tests__/hooks/useStuckSlabs.test.ts` around lines 254 - 264, Update the
“keeps the last-good list when the RPC lookup fails” test for useStuckSlabs so
the initial getAccountInfo call resolves with data and the test waits for a
populated stuckSlabs list. Then make the subsequent refresh() call reject with
the RPC error and assert that the previously loaded stuckSlabs and stuckSlab
values remain unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@app/__tests__/hooks/useStuckSlabs.test.ts`:
- Around line 254-264: Update the “keeps the last-good list when the RPC lookup
fails” test for useStuckSlabs so the initial getAccountInfo call resolves with
data and the test waits for a populated stuckSlabs list. Then make the
subsequent refresh() call reject with the RPC error and assert that the
previously loaded stuckSlabs and stuckSlab values remain unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7a4130b7-735e-4697-9eaa-6399e27ee295

📥 Commits

Reviewing files that changed from the base of the PR and between 309a181 and f0f408a.

📒 Files selected for processing (6)
  • app/__tests__/components/ConnectButton.test.tsx
  • app/__tests__/components/PrivyProviderClient.test.tsx
  • app/__tests__/hooks/useStuckSlabs.test.ts
  • app/__tests__/hooks/useWallet.test.ts
  • app/__tests__/providers/SlabProvider-allowlist.test.tsx
  • app/components/providers/SlabProvider.tsx

…path

CodeRabbit review on #2451 caught this and it is correct: the test asserted
`stuckSlabs` was `[]` right after a failed INITIAL mount, so it was vacuously
empty whether the catch preserved state or blanked it. It proved nothing about
the behaviour its name claims.

Now seeds a successful load, waits for a populated list, then breaks the RPC and
forces refresh() before asserting the list survives.

Mutation-verified: adding `setStuckSlabs([])` to the hook's catch block fails
the new test. The old version passed under that same mutation.

This is the fourth instance of the vacuous-pass pattern in this batch of work,
and the first one that was mine.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dcccrypto

Copy link
Copy Markdown
Owner Author

Good catch on the keeps the last-good list when the RPC lookup fails nitpick — it was right, and it mattered more than "trivial".

The test asserted stuckSlabs was [] immediately after a failed initial mount, so it was vacuously empty whether the catch preserved state or blanked it. It would have passed against a hook that threw the banner away on every transient RPC blip — the exact regression it was named for.

Fixed in fd39566: seeds a successful load, waits for a populated list, then breaks the RPC and forces refresh() before asserting the list survives.

Mutation-verified rather than just "it's green now" — adding setStuckSlabs([]) to the hook's catch block fails the new test, and the old version passed under that same mutation.

Worth noting for anyone reading: this is the fourth vacuous-pass found in this batch of work (see #2452's stake-pool fixtures and #2453's matcher-state negative case) — and the first one that was mine rather than pre-existing. The pattern is consistent enough to be worth watching for repo-wide.

Fifth slice of the 65 CI failures #2450 exposed. Fixes 8, adds 10. Test files
only, no product change.

Each suite asserted a contract the route had deliberately moved away from:

- funding-global (4): the route is no longer a pass-through proxy. It is a
  three-tier chain (Railway proxy -> local indexer Postgres -> graceful empty)
  that intentionally does NOT surface upstream errors, so the funding UI
  degrades to "no data" rather than an error state when the backend is down —
  the live situation whenever Railway is dead (#2443). Tests now assert the
  degradation, and cover the indexer-db fallback tier, which had none: it
  serves rows, applies the blocklist, and falls through to empty when the
  query itself fails.

- middleware-rate-limit (3): these set BLOCKED_MARKET_ADDRESSES and expected a
  404. middleware.ts imports `@/lib/blocklist-edge`, which is edge-pure and
  intentionally omits env-var overrides — importing the env-reading
  `@/lib/blocklist` co-bundled Node-only code into the Edge chunk and Vercel's
  deploy validator rejected it. The tests now pin that boundary explicitly
  instead of failing on it: env-injected slabs are blocked at the Node API
  layer only, and blocking at the Edge requires editing blocklist-edge.ts.

- markets-supabase-outage-fallback (1): the devnet static directory was
  migrated to the single v17 wrapper program, so filtering by an old
  per-slab-tier program id matched 0 rows, not 3. Now asserts the filter's
  behaviour rather than a hardcoded count, plus the negative case — without it
  a no-op filter returning everything would still pass.

Also adds __tests__/unit/blocklist-edge-sync.test.ts. blocklist-edge.ts carries
a "KEEP IN SYNC with HARDCODED_BLOCKED_SLABS" comment and nothing enforced it.
The two lists agree today (35 entries each), but drift would leave a slab
blocked at one layer and reachable through the other — and entries there
include a wrong oracle_authority (price-manipulation risk, GH#837). Includes a
size assertion so emptying one set cannot make the diffs pass vacuously.
Mutation-verified: removing one address from the edge list fails it.

95 -> 87 failures, 28 -> 25 files. tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ed SlabProvider-allowlist test

# Conflicts:
#	app/__tests__/providers/SlabProvider-allowlist.test.tsx
…pper program

The 2026-07 fee-split merge re-migrated the static DEVNET_MARKET_DIRECTORY_FALLBACK
program from 69VUZ7a2… to the fee-split wrapper DhSkE7uTb8…, so the positive
'filters by program_id' test queried a program no longer in the directory and got
0 rows (expected >0). Updates both program-id constants to the current value; the
negative-case test (unknown program → 0 rows) still guards against a no-op filter.
dcccrypto and others added 2 commits July 24, 2026 19:14
…ator

On a staked market the wizard's full create flow (StakeInitPool +
BindInsuranceAuthority) rotates marketauth, insurance_authority AND
insurance_operator to program PDAs. The panel gated on insurance_operator, so on
every real (staked) market it rendered for NOBODY -- no wallet holds a PDA key.

Verified on the live staked market 7FBXdrm1…: insurance_operator = PDA
6a3tiSd2…, but asset_admin = the creator's wallet 7JVQvrAf…. asset_admin
bootstraps to the creator and the stake flow leaves it alone, so it is the field
that reliably tracks the creator through staking.

Matches the on-chain re-gate (wrapper hash 4f5df6be…, tag 90 WithdrawCreatorFee
now checks asset 0's asset_admin).

- lib/v17-creator-fee.ts: read the gate from parseAssetOracleProfileV17(...).
  assetAdmin (SDK-owned, profile-rel 368), not .insuranceOperator.
- useCreatorClaim.ts: isOperator -> isClaimAuthority; error copy "Only this
  market's admin (the creator) can claim".
- CreatorClaimPanel.tsx: badge "You are the operator" -> "You are the creator".
- Tests use three distinct keys (asset_admin=creator, insurance_operator=PDA,
  marketauth=third) mirroring the live market, and assert the panel shows for
  asset_admin and NOT insurance_operator -- reverting the read to insuranceOperator
  turns 20 tests red.

tsc clean, next build green, 53/53 in the creator-claim suites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDXvFwXmYuz5cbJYyczo7u
…d (code-split heavy cards)

Two issues reported on /analytics/[slab]: data lags behind reality, and initial
load is slow.

DATA LAG — SlabProvider used adaptive polling ("30s when wsActive, 3s when not"),
but wsActive latched to true on the FIRST onAccountChange message and was never
reset. A silently-dropped WebSocket (frequent on Vercel/public RPC — there's even
a reconnect-storm note in useWalletCompat) left the page refreshing only every
30s with nothing detecting it. Replaced with a steady 5s backup poll that the WS
rides on top of: WS still delivers ~instant updates when alive, and a dead socket
now costs at most 5s of staleness instead of 30s. bytesEqual in parseSlab dedups
the no-op re-parses, so the steadier poll adds zero extra re-renders. (Also
benefits /trade/[slab], which shares this provider.)

INITIAL LOAD — the analytics page mounted all 11 cards at once, 4 of which scan
every portfolio in the 26KB slab (AccountsCard, AdlLeaderboard,
LiquidationAnalytics, SystemCapitalCard). Code-split those 4 via next/dynamic
(ssr:false) with skeleton fallbacks so they load in their own chunks after the
light top-of-page cards paint, trimming the initial JS bundle and first-render
work for the dashboard route.

tsc clean, next build green (analytics route compiles with the split chunks).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDXvFwXmYuz5cbJYyczo7u
dcccrypto and others added 22 commits July 27, 2026 09:48
…slabs

394f856 ("blocklist the entire devnet-2.0 lineup") added entries to
lib/blocklist.ts but not to lib/blocklist-edge.ts. Those are two separate
hardcoded sets, deliberately duplicated so the Edge middleware gets an isolated
bundle (the file's own header says KEEP IN SYNC), and middleware.ts imports the
EDGE one:

  app/middleware.ts:26  import { BLOCKED_SLAB_ADDRESSES } from "@/lib/blocklist-edge";

So the canonical set had 41 entries while the Edge set had 35, and the 6 newly
blocklisted devnet-2.0 slabs were still reachable through the middleware:

  7FBXdrm1vQ4ktQJjMwurq4cAHkVB1gKoZ7Hx3CAQv6P4
  8SHhSKuY9cun15Y2Q9p9SNEV86zzSWbeP4e59xLAv99h
  BLAHwD5wZ3Wo6naHD4GTT6zpYFcyLWAviEWR4zT7C36p
  BPgSUbDsxZ9bkauWgd6eQ8oLHVx6pSsvfAjPGsS2Sso8
  CseeeuKKbgNU38VRukG38mTdcPJ4KWci5GmFikEtp1X5
  gHey79gB1xGQyXne8yEHoKmGi6jrEVigLwxSXQrYkD3

Adds those 6, leaving both sets at 41 with zero divergence in either direction.
Entries re-sorted case-insensitively so future diffs stay readable.

Found by the blocklist-edge-sync test in #2456, which exists precisely to catch
this class of drift; verified against that test here (it fails if any one of the
6 is removed again, passes with all present).
…ocklist instead

394f856 blocklisted the entire devnet-2.0 lineup. DEVNET_MARKET_DIRECTORY_FALLBACK
is now a SINGLE entry and that entry is itself blocklisted, so the outage fallback
legitimately returns zero rows on devnet — which broke this test's
`markets.length > 0` premise.

Dropping the emptiness assertion rather than propping it up: it was never the
point, and it would re-break the moment the directory is repopulated. The
filter's real contract — everything returned matches the requested program — is
kept.

To make sure removing it did not leave a vacuous test, added a guard that pins
the behaviour that now actually matters: requesting the blocklisted entry's OWN
program must still return nothing, proving the blocklist filter runs ahead of
the program filter rather than the emptiness being incidental.

Mutation-verified: disabling the fallback's BLOCKED_SLAB_ADDRESSES filter fails
the new guard; restoring it passes.

Note (not fixed here, it is a product call): during a Supabase outage the devnet
fallback now serves zero markets. Flagged separately.
The leverage slider computed its max with BigInt division:
`Number(10000n / initialMarginBps)`, which discards the remainder. For the
standard 1500-bps (15%) market the real engine cap is 10000/1500 = 6.6667x,
but the truncation showed 6x — silently denying traders the last ~0.67x the
engine actually permits. Meanwhile the position panel's "Risk Lev." computed
the honest value and displayed 6.7x, so a max order read as "6" on the slider
and "6.7" once open. Same market, two numbers.

Fix (client-side only; the on-chain cap is unchanged):
- maxLeverageFromOnChain now uses float division, floored to 2 decimals so a
  max order lands at 1501.5 bps >= the 1500 required (clears the margin check
  with a hair of buffer rather than sitting exactly on the edge). Displays 6.7x.
- Made the size math fractional-safe. The old `BigInt(leverage)` and
  `BigInt(Math.round(maxLeverage))` throw / overshoot on a non-integer like
  6.66 — buyingPower and notionalNative now scale by 100 before the BigInt.
- All leverage displays route through formatLeverageValue: integers stay clean
  (1x/3x/5x/6x), the max shows 6.7x.

The market list / wizard still advertise the round floored 6x; only the trade
ticket exposes the true achievable ceiling, as intended.

Verified: tsc clean; leverage-display + wizard suites (28 tests) green; the
exact arithmetic checked directly (1500bps -> "6.7x" -> 1501.5bps -> accepted;
BigInt paths no longer throw on 6.66).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017VkS8j4NDFV7BnpDLLjbbQ
Root cause, verified against the deployed engine (2026-07-27):
`withdraw_not_atomic` (percolator/src/v16.rs:14412) returns V16Error::Stale
(Custom 19) UNCONDITIONALLY whenever the account's active_bitmap is non-empty —
i.e. whenever ANY position is open — regardless of oracle/crank freshness. It is
a by-design refusal to release collateral backing a position, NOT a stale-data
condition. Proven: an account whose four cert epochs all match the header still
reverts Custom(19) while a leg is open, and a maintainer crank never clears it.

The app was doing two wrong things:
- Surfacing "the market's data is too stale; needs a maintainer crank" for
  Custom(19) on withdraw — actively misleading; the real remedy is "close first".
- Believing "free margin" (capital − position margin) was withdrawable while a
  position stayed open, and prepending a PermissionlessCrank to "un-stale" the
  withdraw. Neither works: the engine locks the whole account until flat.

Fix (frontend only; the engine behavior is intended and unchanged):
- Block the doomed withdrawal up front when the account has an open position,
  with the true reason ("close your position first"), instead of letting it
  revert on-chain.
- Rewrite the Custom(19) withdraw message and the generic errorMessages.ts copy
  for 19 and 21 to state the real causes (position-open / transient-lag /
  recovery) instead of always asserting "the market needs a re-seed".
- Remove the useless v17 crank-prepend (and its now-unused import + the
  always-false CU flag): it could never help a position withdraw.

NOT a keeper change: the earlier "keeper should crank user accounts" plan was
based on a wrong stale-cert diagnosis and is dropped — cranking cannot empty the
bitmap. Closing (a self-certifying trade) then withdrawing is the supported flow
and works today.

Verified: tsc clean; errorMessages + withdraw suites (76 tests) green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017VkS8j4NDFV7BnpDLLjbbQ
…uardrails

The launch wizard hardcoded two families of parameters that are written ONCE at
market creation and can never be changed (the matcher has no update instruction;
max_price_move lives in the engine config). Both were verified harmful on devnet.

1. maxPriceMoveBpsPerSlot=1 / maxAccrualDtSlots=500 froze NEW positions for as
   long as the settlement price lagged the oracle — ~17 min after a 26% move.
   Proven causally: a converged market opens fine; push -30% and the same trader
   is refused Custom(21) until the gap closes, with the gap shrinking
   monotonically under cranking (2461 -> 2205 bps).

2. The LP had NO guardrails: impactKBps 0, maxFillAbs and maxInventoryAbs at
   i128::MAX, skewSpreadMultBps 0 — a free, unlimited, fixed-price counterparty.
   That is how Jimothy's LP reached $0 capital / -$2,479 pnl.

lib/market-params.ts now derives all of it from the only things a creator picks
(leverage + fee split). Trading fee stays fixed; price-move, accrual window and
LP caps are never shown or typed in.

The solvency envelope makes this a TRADE-OFF, not free headroom. An earlier
reading of `<= 10_000` was wrong: that bound applies only to a special-case
early return. The binding limit is scaled by maintenance margin — bisected
on-chain at `price_move x window <= 500` for 1500-bps margin, with every
combination at 750+ REJECTED by InitMarket. The rate limit is a solvency
guarantee (it stops a price move outrunning liquidation), so higher leverage
buys less of it. MAX_PRICE_MOVE_BY_MARGIN encodes the bisected maxima.

Also removes the basis for the 6.67x leverage floor: the July bisection
concluded "10x fails", but re-testing shows 10x fails ONLY when paired with the
old 1x500 budget. With a compatible budget (4x100) 10x is accepted. Leverage was
never the problem, so creators can now be offered 2x-10x.

Verified on devnet:
- every leverage 2x..10x produces an InitMarket the program ACCEPTS (bisected).
- a market launched with the derived config reads back on-chain as
  max_price_move=6, max_accrual_dt=100, max_fill_abs=600000000,
  max_inventory_abs=2400000000 (was i128::MAX).
- the fill cap BITES: a 300M order lands, a 900M order is refused, so the LP can
  no longer be loaded without limit in a single trade.
- tsc clean; wizard + leverage-display suites (28 tests) green.

Not yet wired: the leverage picker UI (creator still supplies initialMarginBps),
and skewSpreadMultBps is derived but unverified on-chain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017VkS8j4NDFV7BnpDLLjbbQ
Two more launch-path defects found by executing the flow on devnet.

1. ZOMBIE MARKETS. The /api/markets POST — the call that makes a market VISIBLE
   in the app — fired immediately after M1, in parallel with the funding steps.
   A launch that died at M3a still published a listed, unfunded, untradeable
   market. That is exactly what happened to ANSEM: slab + LP portfolio created,
   the deposit never arrived, steps 4-5 never ran, and a broken market appeared
   in the UI with the creator's funds still in their wallet. Because "create the
   market" is always step 1 and always succeeds, EVERY failed launch left one.

   Registration is now deferred behind registerMarketInDb() and called only
   after M3a (DepositCollateral + backing seed) lands, so a market becomes
   visible only once it actually holds collateral. A launch that dies earlier
   leaves an on-chain slab nobody sees instead of a broken listing.

   keeper-register is deliberately NOT deferred: it must still run before M4
   because StakeInitPool rotates marketauth and keeper-register's H1 check
   requires marketauth to still equal the deployer.

   The sequential path already registered after Step 3 and needed no change.

2. SHORTS BACKED BY ONE CENT, FOREVER. Both backing domains were seeded with a
   flat 0.01 test-USDC. That is fine for LONG (DepositToLpVault can top it up)
   but not for SHORT: CreateLpVault overwrites the asset's
   backing_bucket_authority to the vault registry PDA, and that field is shared
   by BOTH domains while a vault serves only domain 0. Afterwards the creator is
   Unauthorized (Custom 8) on TopUpBackingBucket, WithdrawBackingBucket and
   SyncBackingDomainLedger for both domains — verified on devnet, and
   irreversible (UpdateAssetAuthority needs a signature the PDA cannot give,
   CloseLpVault does not restore the field, UpdateAssetLifecycle returns
   AssetSlotAlreadyConfigured on an active asset).

   Whatever SHORT gets at creation is all it will ever have, so the seed is now
   backingSeedPerDomain() = 10% of the LP seed per domain, floored at the old
   0.01 so a tiny LP seed still defuses the freshness deadlock. A $1000 LP seed
   now backs each side with $100 instead of one cent. The tx4 funding
   requirement was updated to cover the larger seed.

Verified: tsc clean; wizard suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017VkS8j4NDFV7BnpDLLjbbQ
MIN_SAFE_INITIAL_MARGIN_BPS = 1500n floored the on-chain initial_margin_bps at
all three InitMarket sites, so every market was created at 6.67x regardless of
what the creator chose. It also disagreed with the rest of the launch: `derived`
sized max_price_move_bps_per_slot for the leverage the creator PICKED while
InitMarket wrote the floored margin, so the two describe different markets.

The floor came from a bisection that concluded "10x fails on-chain". That was a
misdiagnosis. 10x fails only when paired with the OLD hardcoded budget
(1 bps/slot x 500 slots = 500), which exceeds what 500-bps maintenance margin
can absorb. Re-simulated against the deployed program, both halves confirmed:

    im=1000 pm=1 dt=500   ❌ rejected   <- the "proof" that 10x fails
    im=1000 pm=4 dt=100   ✅ accepted   <- same leverage, compatible budget

Leverage was never the problem; the budget paired with it was.

Margin and maintenance now come from deriveMarketParams — the same call that
produces the price-move budget — so the two are coherent by construction and
cannot drift apart again.

Validation bounds were a second silent-clamp of the same kind: they allowed
100..5000 bps (100x..2x) while deriveMarketParams clamps to 10x..2x, so a
creator asking for 20x or 100x passed validation and was handed a 10x market
with no warning. Bounds are now derived from MIN/MAX_LEVERAGE_X so they cannot
drift from the clamp, and an out-of-range request is rejected with the real
number instead of being quietly changed.

Verified against the deployed program: every leverage the wizard offers
(2, 2.5, 3, 4, 5, 6, 6.67, 7, 8, 9, 10x) simulates ACCEPTED, so the wizard
cannot produce a config that fails InitMarket.

Adds market-params.test.ts (22 tests) pinning the bisected envelope table, the
leverage round-trip, the LP guardrails, and the backing seed — these values are
write-once per market and unfixable after creation. App suite back to its
pre-existing baseline (93 unrelated failures on this branch, unchanged).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162bycxSd52kpWco9npQAXr
The caption said yield distribution "isn't active on the deployed program yet,
so APY is currently 0%". That blames the program, and it is false.

Verified on a fresh devnet market: a 500-notional round trip at 30 bps accrued
exactly 1_440_000 atoms of LP fee on the slab (the LP's 48% of $3.00), and a
single LpVaultCrankFees moved all of it into the vault —
lpFeeWithdrawnAtoms 0 -> 1440000, vault feeDistribution 0 -> 1440000.

The program distributes fees correctly. Nothing was CALLING the crank, which
the keeper now does on its own interval (percolator-oracle-keeper @82dde32).

New copy says what is actually true: LPs earn a 48% share of every trading fee,
distributed automatically, and 0% means the market is quiet rather than the
feature being absent.

Left the equivalent line on the stake page alone. Stake may well be the same
shape of problem — StakeAccrueFees exists but has no SDK account list and no
callers — but I have not proven it end-to-end, and rewriting that copy on an
assumption is the same mistake in the opposite direction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162bycxSd52kpWco9npQAXr
…ong number

Three related problems in the create wizard's parameters step.

1. Leverage was not selectable. Creators set "Initial Margin" on a raw bps
   slider ranging 100..5000 (100x..2x), while deriveMarketParams clamps to
   10x..2x — so a request outside that range was silently turned into a
   different market. It also asked for the inverse of the number creators
   actually think in. Replaced with a LeveragePicker (2/3/4/5/8/10x) that
   previews margin, maintenance, price-move budget and recovery time, all read
   straight from deriveMarketParams so the preview cannot drift from what gets
   written on-chain.

2. The advertised leverage was wrong for every leverage that does not divide
   evenly. Margin rounds UP (ceil(10000/lev)), so 3x stores 3334 bps — and
   floor(10000/3334) is 2. A 3x market called itself 2x on the review screen,
   the success screen, AND in the markets DB's max_leverage column. Added
   leverageFromMarginBps() (the exact inverse, rounding) and used it at all
   three sites.

3. Trading fee was creator-settable via a 1..1000 bps slider. Per product
   decision it is one rate for every market: a creator undercutting on fees
   does not make their market better, it starves the LP and insurance shares
   that keep it solvent. The fee is now pinned to FIXED_TRADING_FEE_BPS at the
   source, so a quick-launch preset cannot override it either, and shown as a
   read-only row pointing at the fee split — the knob that IS theirs.

Net effect: the creator chooses leverage and their fee split, and everything
else is derived. That matters here more than usual because these parameters are
write-once — there is no instruction to change a market's leverage, matcher
caps, or price-move budget after creation.

App suite unchanged at its pre-existing baseline (93 unrelated failures on this
branch); market-params tests 25/25.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162bycxSd52kpWco9npQAXr
Swept the app for the class of bug behind the 6.67x leverage cap and the
missing LP caps: a value typed once into an InitMarket/InitMatcherCtx call,
invisible afterwards and permanent for that market.

Findings:

- `impactKBps: 0` is NOT a missing guardrail. market-params.ts listed it
  alongside the caps that really were disabled, which would have sent the next
  reader chasing it. Impact is vAMM-only: compute_passive_execution in
  percolator-match/src/vamm.rs prices off base_spread + trading_fee + skew and
  never reads impact_k_bps, and the impact term zeroes out anyway when
  liquidity_notional_e6 is 0. These markets are kind: 0 (Passive), so 0 is
  correct. Comment corrected to say so explicitly.

- Both I128_MAX constants were dead. The recovery/STEP2 path already derives
  its matcher caps like the merged path does, so nothing referenced them.
  Removed — a leftover i128::MAX sitting next to a matcher call is an
  invitation to reintroduce the bug.

- Documented every remaining pinned parameter with why it is safe to pin.
  Most notable: maxAbsFundingE9PerSlot "0" is deliberate, not an oversight —
  trades settle at the pushed AuthMark, which IS the reference price, so there
  is no perp-vs-spot basis for funding to correct.

Also closed a real gap in the earlier leverage verification. That matrix ran
with minFundingLifetimeSlots=100, but the wizard sends 500 — so it had not
actually tested the shipped config. Re-ran with the wizard's exact args: all of
2/2.5/3/4/5/6/6.67/7/8/9/10x still ACCEPTED, and the control still reproduces
the original misdiagnosis (im=1000 pm=1 dt=500 rejected).

Stake page: copy CHECKED and left accurate, with the on-chain reason recorded
so it does not get "corrected" into a lie the way the Earn caption was. The
wizard creates stake pools via StakeInitPool, which sets pool_mode = 0;
percolator-stake's process_accrue_fees rejects anything but pool_mode == 1 with
InvalidPoolMode. Confirmed on a fresh market: pool_mode reads 0 at offset 280.
So stake earns nothing by design — it is insurance backing, not a fee-earning
pool — and wiring a stake AccrueFees crank would have been work on a call that
can never succeed. Reworded to say what staking IS, and to point at Earn for
fee yield.

App suite unchanged at its pre-existing baseline (93 unrelated failures).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162bycxSd52kpWco9npQAXr
`computeMaxLeverage` used Math.floor(10000 / initialMarginBps). The launch path
derives margin with ceil(10000 / leverage), so 3x stores 3334 bps and
floor(10000 / 3334) is 2 — the markets list advertised a 3x market as 2x, and
so did every consumer of this route.

Same defect fixed on the client in 9b3afe7; this server route was missed
because it kept its own copy of the calculation. Now imports the shared
leverageFromMarginBps so the two cannot drift again.

Also blocklists the 14 harness markets created during this session's
verification sweep. They are real, funded, wrapper-owned markets, so
/api/markets discovers them on-chain and they were showing on the LIVE
playground as untitled "UNKNOWN" rows. They are test fixtures, not products.
Nothing depends on them — the keeper prices only its own registry, which they
were never added to.

Repairs __tests__/hooks/useCreateMarket-fresh-batched-registration.test.ts.
It had been failing since the zombie fix and, more importantly, had stopped
guarding: the slice used `keeperRegisterPromise` as an end marker, the zombie
fix moved markets-registration AFTER it, indexOf returned -1, and every
assertion inside became unreachable. Rewritten against the current structure
and extended to cover what actually broke in production:
  - registration happens only AFTER M3a lands (the ANSEM zombie — a market that
    registered and then never got funded)
  - registration is idempotent (marketsRegistered guard)
  - keeper-register still precedes M4, because StakeInitPool rotates marketauth
    and keeper-register's H1 check needs it to equal the deployer
  - a bounds test on the source-scan itself, so a moved marker fails loudly
    instead of silently disabling the guard

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162bycxSd52kpWco9npQAXr
92 failures -> 31, from two causes that had nothing to do with product code.

Web Storage was missing entirely. `window.localStorage` under this jsdom setup
is a PLAIN EMPTY OBJECT — no getItem, no setItem, no clear (its prototype is
Object.prototype). Every test touching storage died on its first call, which is
why the whole useChartDrawings + useChartDrawingTool suites failed as a block
(29 tests) on `window.localStorage.clear is not a function`. That is one
missing global, not 29 broken behaviours.

The polyfill puts methods on a shared PROTOTYPE rather than on the instance,
because the suites spy via `vi.spyOn(window.localStorage.__proto__, "setItem")`
to simulate quota errors and Safari Private Mode — and because that is how real
jsdom behaves (all Storage shares Storage.prototype). An instance-level
implementation leaves the prototype empty and spyOn throws.

Deleted 10 test files for routes that this branch deliberately removed in
7f5b512 ("cut marketing cruft, lock devnet, decouple Supabase"): the waitlist
signup flow, /api/bugs, /api/ideas, /api/applications. They read route files
off disk and failed ENOENT. Verified every one still exists on origin/main
alongside the code it tests before removing it here — this deletes a duplicate,
not the only copy.

Leaving them red was not free: 30+ permanently-failing tests are the noise that
let a genuinely broken guard (useCreateMarket-fresh-batched-registration, whose
assertions had become unreachable) sit unnoticed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162bycxSd52kpWco9npQAXr
…tracts

Both suites were asserting against superseded storage layouts, so they tested
the empty path while looking like product failures.

useStuckSlabs (8 tests): the mock returned only useConnectionCompat, but the
hook also calls useWalletCompat — and a vi.mock factory REPLACES the module, so
every render threw "No useWalletCompat export is defined". With that fixed the
fixtures still matched nothing: they wrote the legacy single-keypair blob
(`percolator-pending-slab-keypair` = a bare secretKey array) while the hook now
reads wallet-scoped entries via loadAllInFlightMarkets() filtered on
`adminAddress === connectedWallet`. Fixtures now write the real
`percolator:in-flight-market:<slab>` shape against a connected wallet, and the
removal assertions target the per-slab key instead of the retired global one.

useStakePool (2 tests): fixture used the v1 StakePool layout — 352 bytes with
cooldown_slots at offset 162. The struct is now 384 ("prior 352 +
pending_admin[32] = 384" in percolator-stake state.rs) and cooldown_slots sits
at 184. The HOOK was correct the whole time; confirmed against a live devnet
pool, where offset 184 reads exactly the 5 slots the market was created with
and 162 reads garbage. Fixture migrated to the v2 offsets.

Neither was a product bug — but both looked like one, which is the cost of
leaving a suite red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162bycxSd52kpWco9npQAXr
31 -> ~12. All stale-contract, none product bugs.

setup.ts: guard the Storage polyfill behind `typeof window !== "undefined"`.
Some suites opt into `@vitest-environment node`, where touching `window` at
setup time throws ReferenceError and fails the file before its first test —
a regression I introduced in 7817632. Those three suites now pass (49 tests).

useStakeDepositByPool / useStakeWithdrawByPool (18 tests): two stale things.
The SDK mock omitted V17_MARKET_GROUP_OFF, so the import threw before any test
ran; and the pool fixture stamped the RETIRED stake program as the account
owner. The hooks derive against getConfig().vaultProgramId — GCHhcgw…, which is
what is actually deployed (confirmed on devnet) — so the owner guard correctly
rejected the fixture. Fixtures now use the live program and the v2 384-byte
pool size.

PrivyProviderClient: mock omitted useSignTransaction /
useSignAndSendTransaction / useSignMessage. A vi.mock factory replaces the whole
module, so every hook the provider imports has to be listed.

Removed 2 more orphaned suites — Guide.test.tsx and
launch-invalid-json-guard.test.ts import `app/guide/page` and
`app/api/launch/route`, both cut from this branch and both still present on
origin/main with their tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162bycxSd52kpWco9npQAXr
The Edge blocklist was missing 14 entries. I added the harness/QA markets to
lib/blocklist.ts in 67ac873 but not to lib/blocklist-edge.ts, which middleware
imports as a deliberately isolated, zero-import copy — so middleware would not
have blocked them. Caught by blocklist-edge-sync.test.ts, which came in with the
merged api-route-tests branch and exists for exactly this drift. Synced.

Four more fixtures that encoded superseded contracts:

v17-matcher-state: fixtures wrote the delegate at CTX_VAMM_OFFSET, but the
delegate lives at +16 — the 16-byte VAMM magic occupies +0..+16. That is the
precise bug lib/v17-matcher-state.ts was fixed to stop doing (verified against
live devnet contexts), so the fixture encoded the old defect and the
"initialized" case could never pass. One sibling test had been passing for the
wrong reason: a 32-byte write at +0 leaves non-zero bytes at +16, which reads as
a mismatched delegate rather than an empty one.

priceStore-poll: the wsManager mock only had onMessage. priceStore now
subscribes per channel, so the store threw "onMessageForChannel is not a
function" on first subscribe.

gh1654: asserted MarketInfoBar imports MarketLogo directly. The symbol+logo
became a dropdown market switcher, so the assertion now follows the indirection
(bar -> MarketSwitcher -> MarketLogo) and tests the GH#1654 requirement rather
than the old implementation.

Suite: 92 failing -> 14, and 2813 passing (was 2701). tsc clean, production
build succeeds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162bycxSd52kpWco9npQAXr
…allet/ConnectButton retargets

The upstream test-maintenance batch (7817632, 03d3f79, 5d6f715) fixed
PrivyProviderClient.test.tsx and useStuckSlabs.test.ts independently, so this
branch's older versions of those two files conflicted. Both now pass on
playground on their own, so upstream's versions win — this branch adds nothing
there any more.

What this branch still uniquely fixes is the remainder of the wallet-bundle
retarget that upstream did NOT cover: useWallet.test.ts (6 failures) and
ConnectButton.test.tsx (1), which still fail on 8bab3ce.
@dcccrypto
dcccrypto merged commit abc75fe into playground Aug 1, 2026
14 checks passed
dcccrypto added a commit that referenced this pull request Aug 1, 2026
ci: actually run the app test suite, and stop the Merge Gate certifying skips (#2447)

Merged AFTER the test-fix PRs (#2451/#2458/#2459) so the newly-enforcing
Merge Gate switches on against a green suite rather than the 4 files that
were failing before them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
dcccrypto added a commit that referenced this pull request Aug 1, 2026
ci: enable CodeRabbit auto-review on PRs based on `playground`

Merged AFTER the test-fix PRs (#2451/#2458/#2459) so the newly-enforcing
Merge Gate switches on against a green suite rather than the 4 files that
were failing before them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dcccrypto
dcccrypto deleted the fix/app-tests-stale-wallet-refactor branch August 1, 2026 16:42
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.

1 participant