test(header): repoint nav tests at the consolidated Trade group (3 failures on playground) - #2459
Conversation
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>
…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.
The nav consolidation (5550a19) slimmed the Trade dropdown to Markets/Portfolio/Earn — Dashboard, Wallet, My Markets and Stake became tabs of the /portfolio and /earn hubs. Their standalone routes still work; they are just no longer top-level dropdown entries. That commit repointed Portfolio.test but not Header.test, which still probed for a 'Wallet' menuitem and so failed 3/3: Unable to find an accessible element with the role "menuitem" and name /Wallet/i Two different situations, fixed accordingly: - 'shows Wallet link inside Trade dropdown' asserted a nav entry that was deliberately removed. Repointed to Portfolio -> /portfolio, keeping the real intent: the open dropdown exposes its links as menuitems with correct hrefs. - The Escape and outside-click tests only used Wallet as a PROBE for open/closed; their subject is dismissal behaviour. Probe swapped to Portfolio, behaviour assertions untouched. Verified the dismissal tests still bind, not just pass: neutralising NavDropdown's `if (e.key === 'Escape') setOpen(false)` and its mousedown listener makes both FAIL, and restoring them passes.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
…own left Supersedes this branch's first commit. That one repointed the Trade-dropdown probe from Wallet to Portfolio, which was correct for 5550a19 but is now wrong: d6156f5 flattened the desktop nav and REMOVED the Trade and Build dropdowns outright. On the new base all 4 nav tests fail with Unable to find an accessible element with the role "button" and name /Trade/i New shape: Trade / Earn / Create a Market are plain links, Portfolio moved to the right beside the wallet, and Community is the only remaining NavDropdown (it absorbed Developers + Faucet from the old Build group). Tests updated to match, keeping every behaviour that still exists: - new 'renders the flat top-level links' pins Trade/Earn/Create/Portfolio as links with correct hrefs (coverage the old dropdown tests used to give). - 'renders Community as the only dropdown trigger' also asserts Build is NOT a button any more, so a silent regression back to dropdowns would fail. - open / Escape / outside-click now exercise Community — the same NavDropdown component, just the group that survived. Mutation-verified, not merely passing: - neutralising NavDropdown's Escape branch + its mousedown listener FAILS both dismissal tests; restoring passes. - renaming the Portfolio link FAILS the flat-links test; restoring passes.
Updated — the nav changed again under this PR
What changed: that commit removed the Trade and Build dropdowns outright. My first commit repointed the Trade-dropdown probe from Wallet → Portfolio, which was right for New nav shape: Trade / Earn / Create a Market are plain links, Portfolio moved to the right beside the wallet, and Community is the only remaining Tests now match, keeping every behaviour that still exists:
Mutation-verified, not merely passing:
Full suite with the other open repair PRs stacked: 2806 passed, 0 failed. |
Fixes the "old vaults" bug and redesigns both Earn tabs to the terminal look. Data: useEarnStats no longer seeds its list from the hardcoded PLAYGROUND_SLAB_META (the July-10 SOL/JUP/TRUMP/PENGU/BURNIE born-immortal ghosts). It now sources the market list from the live /api/markets (same as /markets), unioned with user-launched registered markets, deduped, gated on a real on-chain LP Vault Registry read — so the LP Vault tab shows the same live markets as /markets with real TVL. PLAYGROUND_SLAB_META kept only as a symbol/logo fallback. Fixes a double "-PERP-PERP" ticker too. Design: loud "IN DEVELOPMENT" banners → compact honest inline NOTE (APY/APR genuinely 0%, deposits = counterparty backing / insurance funding). Sharp terminal styling on vault cards (clear Deposit/Withdraw CTA), the Stake hero toned down + accent-token button, page-level min-h-screen removed so it sits cleanly in the Earn hub tab (still renders standalone at /stake). tsc 0; 22 earn/stake unit tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e, mirrors LP Vault The Stake page read like a marketing landing page (big "Stake. Earn. / Back the Fund." hero, DEPOSIT NOW / Learn More CTAs, centered layout). Rebuilt it as a dense, functional app page in the site's terminal idiom, a sibling of the LP Vault tab: - compact "// insurance lp" eyebrow + "Insurance Staking" title (no hero); - honest inline NOTE (APR genuinely 0%, flushes reduce staked value); - stats strip (Total Staked / Your Deposits / Active Pools / Avg APR); - 2-col main+sidebar: pool cards (VaultCard idiom) + Deposit/Withdraw panel + Your Positions in the main column; "What Staking Backs" + "How It Works" + Risk Notice sidebar (copy matches the verified loss-order: stake pre-funds insurance via admin flush); - sharp corners (rounded-md → rounded-sm/none), mono numbers, uppercase micro-labels, accent-only CTAs; compact empty states. All on-chain logic (deposit/withdraw, cooldown, positions, decodeStakePoolV1) untouched. Renders in the Earn hub tab and standalone at /stake; no min-h-screen. tsc 0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
OI was rendered ~600× too large on sub-cent markets. buildMarketVaultInfo did `total_open_interest / 10^decimals`, treating the raw base-asset "Q" quantity (scale 1e6) as USD — on Percolator ($0.00165) that showed ~$589K instead of the real ~$971. Now prefer the indexer's `total_open_interest_usd` (already notional USD), falling back to (raw / 1e6) × last_price. Fixes the OI figure + the OI-capacity utilization on the Earn page. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…p-dex layout) Thorough LAYOUT redesign, not copy tweaks. Both Earn-hub tabs now mirror the trade terminal (main content + right action rail), the shape Hyperliquid/GMX/ Drift use for vaults: - MAIN: a dense, scannable table (LP: Market · TVL · Utilization · Fee · Your Deposit; Stake: Pool · TVL · Cooldown · Your Stake · APR), selectable rows (accent left-border on the selected row), search + TVL/Volume/Util sort. - RIGHT RAIL (OrderTicket-analogue): clicking a row binds that vault/pool into a sticky Deposit/Withdraw rail with its key figures + your position. First row auto-selects so the rail is always bound. Deposit/withdraw logic reused unchanged (useInsuranceLP / DepositWidget / cooldown / partial withdraw). - Explanatory content (How It Works / Insurance / Risk) demoted from a tall competing sidebar to a slim secondary strip below the table. - The "in development" NOTE is removed on both; the honest 0% APY/APR is now a small muted caption. Sharp corners, mono numbers, uppercase micro-labels. New: VaultRow, VaultDepositRail. useInsuranceLP now sources slab context-first so the rail binds to the selected row (/earn/[slab] route path unaffected). tsc 0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The indexer is now history-only (see percolator-indexer #189). Neutralize reads of dropped tables/columns so the API stops 500-ing against the new Supabase project; live OI/price/insurance are already read from chain on the pages. - funding_history: queryFundingHistory/queryFundingGlobal -> no-op [] (table dropped); /api/funding history + global degrade to empty. - insurance_snapshots: stake/pools computeAprs() -> 0 APR without querying (dropped). - Trimmed dropped market_stats columns (OI/insurance/vault/mark/index/funding etc.) from the SELECTs in markets/stats/prices/stake routes; kept last_price/volume_24h/ trade_count_24h + registry cols. trades/candles/leaderboard unchanged (kept tables). tsc clean. Points at the new PercolatorIndexer Supabase project (env set on Vercel). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017VkS8j4NDFV7BnpDLLjbbQ
…dentity
Two problems, both fallout from the indexer reduction.
1. The market list was EMPTY in production — /api/markets returned
{total:0, zombieCount:4} with four live markets in the DB.
isZombieMarket() keys off vault_balance, c_tot and total_accounts. The
reduction stopped mirroring those into market_stats (they're read live from
chain now) and SELECT_FIELDS stopped requesting them, so the keys are ABSENT
on the row. Every branch read absence as death: `vault_balance ?? null`
collapses undefined to null, `total_accounts ?? 0` to 0, and the null-vault
branch then returned true for everything.
Absent is not zero. isZombieMarket now declines to classify when none of the
three liveness signals was supplied, and both call sites (/api/markets and
/api/stats) forward undefined for columns the query never selected instead of
coercing them to null. Genuinely dead markets are still excluded upstream —
the indexer's sweep sets status=closed + indexer_excluded on dust-vault slabs
and both the view and the query filter those. The 56 existing zombie tests
still pass, so real detection is unchanged.
2. Six unrelated devnet markets all displayed as "SOL/USDC Perpetual".
v17 admin-oracle markets carry no on-chain pointer to a base asset — no Pyth
indexFeedId, no DEX pool, no mainnet CA — so the indexer's hyperp branch
always fell through to its hardcoded baseSymbol="SOL" default. That default
was the only branch that ever ran for them.
Market identity now lives in the database, as the one place it can:
- markets.metadata_source ('auto' | 'manual') marks who wrote symbol/name/
logo_url. The indexer only ever INSERTs missing slabs, so a human's edit is
never clobbered by a later discovery cycle.
- PATCH /api/markets/[slab] sets symbol/name/logo_url and flips the row to
'manual'. Authed with the same HMAC-SHA256 scheme as the keeper register
route: secret never crosses the wire, signed timestamp bounds replay, body
read as raw text and verified before parsing. Fails closed when the secret
is unset. Values are length-checked and stripped of control characters and
markup; logo_url goes through the same allowlist the launch flow uses, and
an explicit null clears it.
- markets_with_stats had to be dropped and recreated, not CREATE OR REPLACEd:
it selects m.*, which is expanded to a fixed column list at CREATE time, and
re-expanding it inserts metadata_source mid-list, which REPLACE rejects.
Done in a transaction with grants restored.
The indexer no longer guesses. It resolves the base asset via the DEX pool
when one exists — taking the base asset's logo, not the collateral's — and
otherwise writes a neutral UNKNOWN / "Market <prefix>" placeholder rather
than inventing SOL. A one-off migration resets the already-guessed rows,
under a predicate narrow enough that it cannot rename a market that really
is SOL (auto + no pool + no CA + no logo).
Verified against the live database: the guessed rows reset to placeholders, the
rebuilt view exposes metadata_source with grants intact, and it still returns
the four active markets.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017VkS8j4NDFV7BnpDLLjbbQ
…it SHA The SDK was pinned to github:dcccrypto/percolator-sdk#497ec33. That commit is now published as @percolatorct/sdk@4.3.0 — the registry tarball's shasum matches the artifact built from that exact commit — so this is the same code by a more reliable route, not a version bump. Git dependencies are fetched and built on every install, which is why PLAYGROUND.md carried a standing "if pnpm install fails fetching the SDK, it's almost always a transient GitHub hiccup — just re-run" caveat and a matching troubleshooting row. Installing from the registry removes that failure mode, so both are gone rather than reworded. Docs updated only where they described the old mechanism (setup section, troubleshooting row, CLAUDE.md golden rule); nothing else touched. Verified: resolves from .pnpm/@percolatorct+sdk@4.3.0 rather than a git checkout, tsc clean, production build succeeds. The test suite is unchanged at 29 failing files / 92 failing tests — identical to the counts before this change, all in waitlist / Privy / chart-drawing / stake-hook areas that predate it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017VkS8j4NDFV7BnpDLLjbbQ
…and dead proxy
/api/markets had TWO alternative paths — an on-chain one that computed live
price and OI, and a Supabase one that did not — and the Supabase path wins
whenever the DB is configured. So configuring Supabase silently downgraded the
endpoint. Every symptom traced back to that: activeTotal: 0 with four live
markets, totalOpenInterest: 0 while /api/open-interest reported 588,928,150,764
from the same slab, and liveness heuristics reading absent columns as evidence
of death.
The fix is to stop branching. Registry data (identity, logo, 24h volume) comes
from Postgres because only Postgres has it; live state (price, OI, insurance,
vault, c_tot) comes from the chain because only the chain has it. One merged
row, one loader, shared by both routes.
1. lib/live-market-state.ts — batched live read
One getMultipleAccountsInfo per 100 slabs, using addresses the registry
already supplied. This REPLACES the getProgramAccounts LP-capital scan that
ran in the list request path: that call scans the entire program, so its cost
tracks protocol size rather than market count, and it is the first thing an
RPC provider throttles. Discovery of new markets belongs to the indexer,
which already does it.
vault_balance now carries the engine vault from the market-group header
rather than LP-portfolio capital. In this route it is only a liveness and
phantom-OI guard, never a rendered column, and the engine vault is the value
those guards were written against. The trade-page detail view keeps its own
single-market LP lookup, which is a different figure.
2. lib/market-registry.ts — one loader, shared
/api/stats used to HTTP-fetch /api/markets to stay consistent with it (they
had drifted badly enough to render "161 markets / $237K OI" beside pages
showing 6). Correct in result, but it costs a second serverless invocation
and its cold start on every stats request, and duplicates all the RPC work.
Sharing the loader gets the same guarantee structurally, in process.
3. Third instance of absent-is-not-zero, and the one that hid OI
isPhantomOpenInterest(accountsCount, vault) treats accountsCount === 0 as
phantom. total_accounts is no longer mirrored, and callers coerced the absent
value to 0 — asserting "this market definitely has no accounts". That made
EVERY market's OI phantom and zeroed it. Unknown now abstains; a KNOWN zero
still suppresses, and the dust-vault condition (which carries the guard now
that vault is read live) is untouched. Tests pin both directions.
4. Dead percolator-api proxies removed
/api/prices and /api/prices/[slab] called ${NEXT_PUBLIC_API_URL}/prices on a
host that answers "Application not found" — a guaranteed-failing round trip
on an endpoint useLivePrice polls every 10s per market. The Pyth and
GeckoTerminal fallbacks were already doing the work and are now the primary
path. The indexer that replaced that service is ingest-only: /health and the
Helius webhook, nothing else.
5. stale-while-revalidate 30s -> 60s
A true split by volatility would need separate identity/live endpoints; not
worth doubling client requests at this market count. Widening the revalidate
window is the lever that matters — inside it the CDN answers from cache and
refreshes in the background, so only the first request after expiry waits on
origin.
Verified against the live database via a local server on the production env,
diffed field-by-field against the deployed response: no key or market removed,
insurance_fund/insurance_balance added, activeTotal 0 -> 4, marketsWithPrice
0 -> 4, prices and OI populated. Percolator's merged OI is 588928150764 —
an exact match with the dedicated /api/open-interest endpoint reading the same
slab, which independently confirms the parse. Full suite unchanged at the
pre-existing 29 files / 92 tests failing, plus 7 new passing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017VkS8j4NDFV7BnpDLLjbbQ
Hides all six rows of the `markets` table from the API and UI: Percolator,
Jimothy, TROLL, the dead SOL slab, the empty BLAHwD5w slab, and ANSEM.
Blocklisted rather than deleted from the DB because neither DB lever holds.
StatsCollector.syncMarkets() re-inserts any on-chain market missing from the
table every COLLECT_INTERVAL_MS (60s) via insertMarketRow({status:"active"}),
so a DELETE reappears within a minute as an UNKNOWN placeholder, minus the
metadata it destroyed. indexer_excluded is self-reverting too: the auto-close
sweep flips it back to false for anything still in the discovery map with
vault > $1 or numUsedAccounts > 0 (StatsCollector.ts:901) — which covers the
one visible bad market, BPgSUbDs (vault 60,496,656,666). This blocklist is the
only lever independent of on-chain state.
Each entry records its verified condition. The two that were actually broken:
- Jimothy — LP vault bankrupt (capital $0.00, pnl -$2,479). Sole negative-PnL
account, so negative_pnl_account_count=1 pins bankruptcy_hlock_active=1,
try_clear_bankruptcy_hlock_if_healthy() can never fire, h_lock_lane returns
HMax, and every favorable action reverts Custom(21).
- SOL/USDC — asset0.slot_last 1,143,246 slots behind (~5.3 days), oracle_epoch=0,
no matcher-enabled LP portfolio so the recovery cranker has never cranked it.
Percolator and TROLL were healthy at time of writing and are retired with the
lineup rather than for a fault; Percolator's LP was draining ($240.35 ->
$215.91 in ~12 min), on the same trajectory as Jimothy.
Verified: tsc --noEmit clean; markets-slab-blocklist-symbol 10/10. The suite's
29 failing files / 92 failing tests are pre-existing and identical with and
without this change (confirmed by stashing it and re-running).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017VkS8j4NDFV7BnpDLLjbbQ
…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
… 1.5s resolveTokenLogo runs two sequential 5s-timeout fetches, and it sat immediately before the DB write — so a slow logo API could add up to 10s to a launch. This route is awaited between M3a and M4, so that was squarely on the critical path. It now starts before the two RPC round-trips this route already makes (slab ownership + pool classification) and is awaited with a 1.5s deadline at the write, by which point it is normally already resolved. Past the deadline the market registers without a logo rather than making the user wait — the success screen already offers a manual upload. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fc1ENe39ToovDfqGWw2mLT
…devnet launch The Step 3 gate read `wizard.oracleType === "keeper"`, but nothing sets that field until handleParametersContinue runs — the Continue handler itself. Its initial value is "admin", so on devnet: registrable = false -> step3Valid = false -> Continue disabled -> the click that would have set "keeper" can never fire Every devnet market launch has been impossible since the gate landed, and silently so: notRegistrableReason was computed but only ever surfaced in an alert() at launch, which the dead button made unreachable. Derive the oracle once, from detection (`resolvedOracleType`), and have both the gate and the Continue handler read it. Same rule, evaluated before the click instead of by it. A token with no supported pool is still refused — that part was correct and is unchanged. Also surface the reason: StepParameters takes `blockedReason` and renders it above the buttons. Every other blocker on that form is visible at the field that causes it; this one has no field, so a dead button was the whole UI. Fixes isAdminOracle too, which read the same stale field and so labelled every keeper market as admin-priced. tsc clean; 2846 tests pass (14 pre-existing Privy/wallet failures unchanged, verified by re-running them with these two files stashed). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fc1ENe39ToovDfqGWw2mLT
Step 3's Continue could be greyed out with no explanation anywhere on the page. Most of step3Valid's clauses are visible at the field that causes them, but an invalid fee split, a fee/margin conflict, an unresolved opening price and an unregistrable token are not — from the user's side that is indistinguishable from the app being broken. Adds a reason for every clause, in the order step3Valid evaluates them, routed through the blockedReason banner StepParameters already renders. Also drops a duplicate prop I had just added for the same purpose: blockedReason was already declared, rendered and passed — I did not check before adding notRegistrableReason alongside it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fc1ENe39ToovDfqGWw2mLT
Two bugs, both from treating "keeper" as if it were neither admin nor pyth. 1. A PYTH ORACLE ACCOUNT ON EVERY KEEPER CRANK. The gate was `!isAdminOracle && !isHyperpOracle`, which is TRUE for a keeper market — and a keeper market's `oracleFeed` is the mainnet DEX POOL address, not a Pyth hex feed id. So every keeper launch derived a push-oracle PDA from a pool address and appended that account to its crank. Keeper markets are AUTH_MARK/admin-mode on chain with no Pyth account at all. Now gated on `oracleMode === "pyth"`, in both the batched and sequential paths — the source-scan test caught that the first fix had only covered one of them. The v12 `!isV17Slab` branch has the same shape but is unreachable for v17 markets, so it is left alone. 2. oracle_authority WAS NULL FOR EXACTLY THE MARKETS THE KEEPER DRIVES. The registration payload gated on `isAdminOracle` (oracleMode === "admin"), so a keeper market recorded no authority — despite the delegation to the crank wallet being the entire point of the mode. Fauci's row shows the symptom. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fc1ENe39ToovDfqGWw2mLT
…d Meteora Two reasons a newly launched market had no live price. 1. THE FEED'S MARKET LIST WAS PINNED AT DEPLOY TIME. local-price-ws-server hardcoded six slabs — four Pyth majors plus two pump.fun pools — from the 2026-07-10 re-seed. Any market created afterwards was in neither map, so the socket streamed nothing for it and the price only moved when the page re-read on-chain. From the user's side that is indistinguishable from a broken feed, and it is why the ticker "stopped working" on new markets while the old ones were fine. The list is now the seed plus every keeper_status='active' row in the database, refreshed every 60s — the same source of truth the oracle keeper reads, so a market ticks as soon as it registers with no redeploy. Pool dexType is resolved from each pool's on-chain owner and cached; a query failure keeps the previous list rather than going silent. 2. THE SAME WSOL->USD BUG AS THE KEEPER, ON THE DISPLAY SIDE. dexPoolReader called computeDexSpotPriceE6 for meteora-dlmm with no solPriceE6, and the SDK only applies that conversion for pumpswap — so a WSOL-quoted Meteora market would have rendered a SOL-denominated price, low by the whole SOL/USD rate (~80x), exactly as the on-chain feed did before 2026-07-29. Converted via the e12 reading for the same precision reason as the keeper fix. Verified locally against the live database: "market list: 2 -> 4 (2 from db)", then both new markets streamed — CATE $0.004441 against DexScreener $0.004496. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fc1ENe39ToovDfqGWw2mLT
…Overview
A closed position still has a portfolio account in its market (positionSize 0,
rendered "Flat", often holding idle capital), so `usePortfolio().positions`
returns it. The dashboard Overview counted/listed that raw array unfiltered, so
a market the user had closed kept showing as an active position — e.g.
"OPEN POSITIONS (2)" and "ACTIVE POSITIONS 2" with a Flat row for a closed
market.
The canonical open/flat split already exists elsewhere: PortfolioPositionsView
separates open positions from idle deposits via `account.positionSize !== 0n`,
and the site-wide PositionsBar filters the same way. The dashboard components
simply never applied it.
Fix: add a shared `isOpenPosition(pos)` helper in usePortfolio (keyed on
`account.positionSize !== 0n`, matching the existing canonical definition so all
open-position surfaces agree) and apply it in the four Overview components that
count or list positions:
- PositionSummary — the "Open Positions" list + count
- DashboardHeader — the "Active Positions" stat
- PnlChart — the "Across N positions" caption + empty-state gate
- StatsBar — stats + per-market fee readout + empty-state checks
(win/loss already ignored flat rows via the >0/<0 PnL test; this also
corrects the length-based cases and is memoized to stay referentially stable)
Filtering is done consumer-side, not at the hook, so surfaces that legitimately
need every account (idle deposits, my-markets) are unaffected. A funded-but-flat
account (idle deposit) is correctly treated as NOT open; an all-flat book now
shows the proper empty state.
Tested: tsc clean; new isOpenPosition unit suite (6 cases: open long/short,
closed size-0, funded-but-flat idle, missing account, mixed-book filter) green;
the one pre-existing AtRiskBanner test failure reproduces identically on the
untouched baseline (unrelated to this change).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CATE's LP was drained to $0 by real on-chain bot churn (two-level ±1.6% oscillation) that the engine ratchets into one-way losses; the market sits in a bankruptcy hlock that can never clear and blocks every user's close and withdraw. Percolator never traded but was launched on the same raw-spot feed. Both retired so the board restarts on the median-smoothed AuthMark keeper (percolator-oracle-keeper 2a3aa87/cbeddf0). Both lists + edge copy updated; sync tests 24/24. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CsKicDEup62NamSzUnEGXJ
…ied 2026-07-30) Applied to the live DB during the registration consolidation; the file was never committed. Recorded so the migration history matches the schema. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CsKicDEup62NamSzUnEGXJ
…-cap rejections
The ticket already blocked orders over the matcher's per-trade cap
(maxFillAbs), but the SECOND ceiling — the LP's net-inventory cap
(maxInventoryAbs) — was invisible: a one-sided market fills up and from then
on same-direction orders revert with the same bare InvalidAccountData while
the user "just keeps trying and doesn't know what's wrong".
- lib/matcherCaps.ts: cache the matcher-context ADDRESS (immutable like the
caps) so live reads are one getAccountInfo; parseMatcherInventory reads
the LP's signed inventory_base (ctx-relative 96, i128); getMatcherInventory
fetches it fresh — never cached, every fill moves it.
- lib/marketCapacity.ts (new): remainingSideCapacityQ /
wouldExceedInventoryCap with the vamm.rs sign conventions
(lp_inventory_delta = -fill_size) documented and pinned by exact-value
tests, including crossing zero to the far bound and the live CATE numbers.
Mutation-verified: flipping the long/short formula fails 7 tests.
- hooks/useMarketFillCap.ts: returns caps + live inventoryBase (immediate
read + 20s pollWhenVisible, in-flight guarded).
- OrderTicket: a "Max per trade / {side} capacity left" row lives under the
size input so the limits are visible BEFORE they're hit (values turn red
when the current order violates them), and a direction-aware blocking
banner explains the inventory case ("market can only absorb ~X more long
exposure — reduce size or wait for shorts/closes") instead of letting the
matcher reject.
Suite: marketCapacity 13/13, blocklist 24/24, tsc clean. The 4 failing files
(ConnectButton/Header/Portfolio/useWallet) fail identically on the base
commit — pre-existing, untouched by this change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CsKicDEup62NamSzUnEGXJ
…l the phantom warmup bar Two halves of "the user just keeps trying and doesn't know what's wrong": CLOSES COULD NOT EXCEED THE FILL CAP. A position can legitimately be 4x the matcher's per-fill cap (maxInventoryAbs = 4 x maxFillAbs; it was built over several trades), but Close sent ONE TradeCpi for the full size — the matcher clamps without partial-fill, the wrapper rejects, and "Close 100%" on any large position failed forever with a bare InvalidAccountData. - lib/closeChunks.ts: near-equal leg split (no dust leg to trip min-size floors), sign-preserving, exact-sum — pinned by exact-value tests including the 4x structural maximum and CATE-scale numbers. - useTrade: optional `sizes` legs; >1 leg builds BatchTradeCpi (tag 67) — same 7 accounts, several matcher fills, ONE signature. Sum invariant enforced at runtime; compute budget scales 600k + 250k/extra leg. - useClosePosition: chunks via getMatcherCaps (cached ctx address, one account read); caps-read failure degrades to the old single-leg path. - ClosePositionModal: "Closes as N fills" explainer (per-market mounts pass maxFillAbs; cross-market lists hide the line but still chunk correctly). THE PHANTOM "TIMER". WarmupProgress was built for the v12 engine; on v17 the /api/warmup endpoint 501s permanently, but only 404 stopped the 5s poll — so every v17 position flashed the loading skeleton every 5 seconds forever: a timer that appears and vanishes eternally. 501 is now terminal like 404, and the skeleton only shows while the FIRST answer is pending. The v17 engine has no per-position warmup (verified in v16.rs: h lanes gate margin credit under stress, not a time ramp), so rendering nothing is the honest state. tsc clean; closeChunks 7/7 + marketCapacity 13/13; full suite unchanged (the 4 failing wallet/header files fail identically on the base commit). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CsKicDEup62NamSzUnEGXJ
…ed-cap sentinel, caps TTL+invalidation, poll hygiene, DataView reads From tonight's three-agent audit of the session's changes: - useDeposit throws on blocklisted slabs: the trade page still renders on a direct URL and deposits into a retired market can be UNRECOVERABLE (a bankruptcy-hlocked market rejects every withdrawal — exactly how CATE died). Withdrawals stay ungated; no new money gets in. - marketCapacity: maxInventoryAbs == 0 means UNLIMITED on-chain (vamm.rs v3-compat), not zero capacity — returning 0 hard-blocked every order on such a market in both directions. Now an unlimited sentinel; capacity row hides, nothing blocks. - matcherCaps: the forever-cache premise was wrong — SetMatcherConfig (68) can re-point/disable the LP's matcher config any time, leaving closes chunked by a dead cap for the whole session. Caps + ctx address now carry a 5-min TTL and invalidateMatcherCaps() heals immediately on any trade or close failure (wired into useTrade's catch). Reads converted to DataView: Buffer.readBigUInt64LE does not exist on the webpack Buffer polyfill, and the resulting throw was swallowed into "no caps" — silently disabling the entire safety layer in a webpack build while Node tests stayed green. - useClosePosition humanizes errors with the "trade" context — its failures (including the single-leg fallback) surfaced the exact bare "Invalid account data" copy this workstream exists to kill. - useMarketFillCap: state resets on market switch (market B briefly validated against market A's caps), and the 20s inventory poll starts only once caps resolve — a matcher-less market (v12/mock/broken) was running a full getProgramAccounts scan every 20s per mounted component, forever. - useTrade: multi-leg batches hard-fail on non-v17 markets (tag 67 does not exist on the v12 wrapper; unreachable today, cheap to make structural). tsc clean both repos; marketCapacity+closeChunks 20/20; full suite unchanged (4 pre-existing wallet/header failures, identical on base). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CsKicDEup62NamSzUnEGXJ
… crowd can hit the LP exposure cap too Audit F4: chunking only handled the per-fill cap; a close that pushes LP inventory FURTHER (closing a long while the LP is already long) still gets clamped by the matcher and rejected bare. Now the close pre-checks the chosen side's remaining capacity and fails with the real reason and the closeable percentage. Capacity errors rethrow; read failures degrade to the old single-leg path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CsKicDEup62NamSzUnEGXJ
… longer masquerades as live Audit F1.3/#2: /trade/<retired-slab> rendered the full live terminal with a silently frozen price. The page still renders (it is the only self-serve surface for funds stuck in a retired market) but now carries a close-and- withdraw-only banner, and the OrderTicket blocks new positions with the real reason as its highest-priority validation issue. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CsKicDEup62NamSzUnEGXJ
…oss-repo blocklist tests Audit F1.1 (HIGH): every portfolio surface filters through the blocklist, so a user with deposits in a RETIRED market saw 'no positions' and a total that silently excluded their money (the CATE depositors' ~$990). useRetiredHoldings bypasses market discovery entirely — one wallet-wide getProgramAccounts scan — and the RetiredHoldings section renders only when blocklisted markets actually hold the user's capital or an open position, linking to the trade page's close/withdraw-only mode. Plus two audit-verification suites: matcherCaps cache TTL/invalidation semantics (fake-timer driven, counts real RPC calls; a forever-cache regression fails) + byteOffset-robust DataView parsing; and the cross-REPO blocklist sync check (app ⊆ indexer, mutation-verified — removing one indexer entry fails it; skips loudly when the sibling checkout is absent). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CsKicDEup62NamSzUnEGXJ
… the blocklist Audit #6: a client subscribing to a RETIRED slab was replayed the last pre-retirement price stamped with a fresh timestamp — a frozen price presented as live — and pinned seed entries streamed forever regardless of retirement. Removal now evicts the cache, and the seed list is filtered through the blocklist on every refresh. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CsKicDEup62NamSzUnEGXJ
… slate complete 78enGzvj… and CZBmJF8m… are small-tier (8538B) shells abandoned 2026-07-22 with vault=0, insurance=0, c_tot=0 — no user funds, never traded. With these all 33 market slabs on wrapper DhSkE7uT… are retired, so the board is empty from discovery down and only newly-launched markets can appear. (The indexer's '62 markets' double-counts: the v17 and v12 scanners both run over the same program, so 33 unique slabs surface twice.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CsKicDEup62NamSzUnEGXJ
…elete the duplicated copy Answering 'why am I editing so many files': the app kept TWO hand-maintained copies of every blocked address. The reason was real but fixable — the Edge middleware cannot import lib/blocklist.ts (it layers process.env overrides on top and is pulled in by many Node-runtime route handlers, so Vercel's Edge validator rejects the build), and the old workaround was to duplicate the whole array in blocklist-edge.ts with a test to catch drift. Now lib/blocklist-data.ts holds the addresses as PURE DATA (zero imports, zero side effects, no process.env) and both consumers import it. The edge bundle stays clean because there is nothing in that module to make it dirty, and drift is impossible by construction rather than caught after the fact. blocklist-edge.ts is 23 lines and holds no addresses; blocklist.ts keeps only the env-override layering. Verified: tsc clean, all 20 blocklist tests pass unchanged, and a full compiles the middleware — the exact check the duplication existed to satisfy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CsKicDEup62NamSzUnEGXJ
fix(nav): point the "Trade" nav link at the markets browser, not a default market Reviewed: diff read in full, no overlap with the 2026-07-31/08-01 blocklist, retired-market and matcher-caps work on this branch. Merged locally after verifying tsc + the full app suite, because the PR's own CI reported the Vercel checks as 'Authorization required to deploy' and the test jobs as 'skipping' — no usable signal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fix(trade): hide the analytics dock while the footer is on screen Reviewed: diff read in full, no overlap with the 2026-07-31/08-01 blocklist, retired-market and matcher-caps work on this branch. Merged locally after verifying tsc + the full app suite, because the PR's own CI reported the Vercel checks as 'Authorization required to deploy' and the test jobs as 'skipping' — no usable signal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fix(theme): brighten the dim dark-mode text tiers for legibility Reviewed: diff read in full, no overlap with the 2026-07-31/08-01 blocklist, retired-market and matcher-caps work on this branch. Merged locally after verifying tsc + the full app suite, because the PR's own CI reported the Vercel checks as 'Authorization required to deploy' and the test jobs as 'skipping' — no usable signal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fix(portfolio): exclude closed (size-0) positions from the dashboard Overview Reviewed: diff read in full, no overlap with the 2026-07-31/08-01 blocklist, retired-market and matcher-caps work on this branch. Merged locally after verifying tsc + the full app suite, because the PR's own CI reported the Vercel checks as 'Authorization required to deploy' and the test jobs as 'skipping' — no usable signal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
a42d9eb repointed the Trade CTA from /trade (which auto-redirected into one arbitrary default market) to /markets, and relabelled it 'Trade — browse all markets'. This test still asserted the old aria-label and href, so it failed with Unable to find an accessible element with the role "link" and name /Trade terminal/i Updated to the new label and target. Matching the full label rather than a bare /Trade/i keeps it from also matching the mobile menu's own 'Trade' entry. Mutation-verified: reverting the CTA's href to /trade fails this test; restoring /markets passes.
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>
What broke
Header.test.tsxfails 3/3 onplayground(5550a19d):Confirmed on the untouched base, so this is not from any open PR.
Why
The nav consolidation (
5550a19d) slimmed the Trade group to Markets/Portfolio/Earn — Dashboard, Wallet, My Markets and Stake became tabs of the/portfolioand/earnhubs. Per that commit's own description, the old routes "stay working standalone"; they are simply no longer top-level dropdown entries.That commit repointed
Portfolio.testbut notHeader.test, which still probes for aWalletmenuitem.Fix — two different situations, not one blanket rename
shows Wallet link inside Trade dropdown/portfolio, preserving the intent: an open dropdown exposes its links asmenuitems with correcthrefsdismisses … on Escapedismisses … on outside clickVerification — the dismissal tests still bind
Swapping a probe can quietly make a test vacuous, so I mutation-tested it:
NavDropdown'sif (e.key === 'Escape') setOpen(false)and itsmousedownlistener neutralisedFull suite (
app/) with the open test-repair PRs stacked: 2805 passed, 0 failed.Note
Invisible to CI for the same reason as #2457/#2458 — see #2447:
Unit Tests/Integration Tests/Coverage Gatereport SKIPPED while the Merge Gate reports SUCCESS.🤖 Generated with Claude Code