Skip to content

fix(stake): decode v4 (408-byte) StakePool; keep SIZE aliases on deployed v3 - #382

Open
0x-SquidSol wants to merge 1 commit into
dcccrypto:mainfrom
0x-SquidSol:fix/stake-pool-v4-408
Open

fix(stake): decode v4 (408-byte) StakePool; keep SIZE aliases on deployed v3#382
0x-SquidSol wants to merge 1 commit into
dcccrypto:mainfrom
0x-SquidSol:fix/stake-pool-v4-408

Conversation

@0x-SquidSol

Copy link
Copy Markdown
Contributor

Addresses items 1 and 1b of #381. Leaves the rest of that issue open — it bundles several other drift items.

Problem

percolator-stake d0c6ecb"promote #242 cooldown timelock out of _reserved (v4, 392 -> 408)" — is a security fix. On the deployed v3 layout, pending_cooldown_slots and cooldown_proposed_at_slot were packed into _reserved[10..26], which PERC-313 HWM state already owned. Proposing a cooldown flipped hwm_enabled off and rewrote hwm_floor_bps; an HWM refresh rewrote the proposal slot so the timelock read as long-elapsed — a bypass of the delay that protects stakers from the admin. v4 gives both fields real struct slots at absolute 392 and 400.

The SDK is pinned to v3/392. A 408-byte pool satisfies data.length >= STAKE_POOL_SIZE_V3, so decodeStakePool concludes v3, expects version 3, reads 4, and throws:

Error: StakePool unsupported version: 4 !== 3

Every stake read dies at once. Not broken today — devnet GCHhcgw… holds 25 pools, all 392 bytes / version 3 — but both programs' main are already on v4 (percolator-stake state.rs const-asserts STAKE_POOL_SIZE == 408; percolator-prog v16_program.rs pins STAKE_POOL_LEN = 408 / STAKE_POOL_VERSION = 4), so it fires on the next stake redeploy.

The second half fails silently: reading the cooldown fields at the old _reserved offsets on a v4 pool returns HWM state as cooldown state. No throw, just wrong numbers.

Fix

The timelock fields are read version-aware, not simply relocated. On v3 those bytes genuinely are the aliased ones — that is what the deployed program writes — so relocating unconditionally would misreport every pool that exists today. isV4 reads 392/400; v3-and-earlier keep reading _reserved[10..18]/[18..26], bug-for-bug.

StakePoolState.version is now exposed. The decoder already read the version byte and threw it away. Field meaning depends on it, not just offsets: on a v3 pool with HWM configured — i.e. what those 25 live pools look like — cooldownProposedAtSlot decodes non-zero with no proposal ever made, which is this interface's own documented "live proposal" sentinel. A caller had no way to tell. Now: gate on version >= 4. A test reproduces exactly that scenario.

The one deliberate omission

STAKE_POOL_SIZE stays 392 and STAKE_POOL_CURRENT_VERSION stays 3.

I originally flipped both. Two reviewers independently pushed back, and they were right. The decoder change is a strict superset — it widens what decodes and changes no existing pool's meaning. The aliases are a swap, and they are bare numbers consumers use as an exact getProgramAccounts({ dataSize }) filter and as a data.length < SIZE gate. In percolator-launch:

  • app/app/api/stake/pools/route.ts:191filters: [{ dataSize: STAKE_POOL_SIZE }]; at 408 this matches zero accounts and the endpoint returns { pools: [] }.
  • :132if (data.length < STAKE_POOL_SIZE) return null; rejects every deployed pool.
  • app/__tests__/hooks/useStakePool.size-cutover.test.ts:23 asserts STAKE_POOL_SIZE === 392.

No consumer breaks today (both pins are ^4.3.0, and a caret cannot cross a major), but the aliases would be wrong about the chain the moment anyone bumped. They flip with the coordinated deploy. STAKE_POOL_SIZE_V4 is available for anyone who wants the new layout explicitly.

specs/stake-parity.json 392 -> 408

I had left this alone believing it described the deployed program. It doesn't: scripts/update-parity-fixtures.mjs generates it with cargo run --bin sdk_parity_fixtures against the sibling repo, and parity-gate.yml checks that repo out with no ref: — i.e. main, which is d0c6ecb/408. So 392 was drift. The deployed layout is now pinned separately as a literal, so it stays guarded. The _reserved offset assertions need no change: state.rs const-asserts _reserved == 320, and v4 appended rather than recarving.

Heads-up on CI: the Parity Gate fires on PRs touching src/solana/stake.ts, and it cannot pass — sdk_parity_fixtures does not exist in percolator-stake (no [[bin]], no src/bin/, nothing in its history); only percolator-match has one. This is pre-existing, not caused by this PR, but this PR is what makes it visible. The fixture was hand-edited, as ccd50f9 also did.

Also included

Doc corrections the change made necessary. The large _reserved byte-aliasing block described the exact bug v4 fixes as unconditional present-tense reality; it is now scoped "v3 and earlier ONLY; FIXED in v4", with the original report retained because it still describes every pool on devnet. totalRecoveredFromWrapper was labelled "v3 only" but is v3 and v4. I also removed a dangling docblock my own first draft orphaned above the V4 constant.

The v4 timelock reads are expressed as reservedStart + 72/+ 80 rather than bare 392/400, so they share a base with every other post-_reserved field. An off bug would then shift both halves together instead of making them disagree — which matters, because this PR adds a comment warning about precisely that failure (omitting a version from the pending_admin gate leaves off at 288 and shifts everything after _reserved by 32). I hit that bug while writing this change; the widened isV2 || isV3 || isV4 gate is the fix.

Verification

  • npx tsc --noEmit clean; npx vitest run -> 29 files, 1011 passed, 31 skipped, 0 failed.
  • A PoC pinning the pre-fix failure, a v3 control proving the aliased reads still match the deployed program, a v4/v3 differential on the fabricated-proposal scenario, and a constants test asserting the alias/decoder asymmetry deliberately.
  • Reviewed by three agents. One reported a blocking "the wrapper is still 392/v3" — it had read my local checkout, 17 commits behind origin/main. origin/main is 408/v4.

Sequencing

Merge is safe now — ci.yml publishes only on a release:-prefixed commit, so a normal merge exposes no consumer. Publish with the deploy, not before: percolator-prog pins v4 exclusively and rejects 392-byte pools on length before reading the version byte, and percolator-stake has no migration path, so the deploy is a flag day that re-seeds all 25 pools. Worth a follow-up on percolator-launch for route.ts:132,191 so the flag day is already handled there.

🤖 Generated with Claude Code

…oyed v3

percolator-stake d0c6ecb promoted the dcccrypto#242 cooldown timelock out of
`_reserved` (392 -> 408). A 408-byte pool satisfies `length >=
STAKE_POOL_SIZE_V3`, so the decoder concluded v3, expected version 3, read
4, and threw `StakePool unsupported version: 4 !== 3`. Every stake read
fails at once the moment the stake program redeploys.

Add STAKE_POOL_SIZE_V4 = 408 and a v4 arm. The two timelock fields are read
version-aware, not simply relocated: on v3 they genuinely live in
`_reserved[10..26]` aliased with PERC-313 HWM state, so reading the v4
offsets would misreport all 25 pools live on devnet today.

Expose `StakePoolState.version` (previously read and discarded). Field
meaning depends on it, not just offsets: on v3 `cooldownProposedAtSlot` can
read non-zero where no proposal exists, which is the interface's own
documented "live proposal" sentinel, and callers had no way to tell.

Deliberately NOT re-pointing STAKE_POOL_SIZE / STAKE_POOL_CURRENT_VERSION.
The decoder change is a strict superset; the aliases are a swap. Consumers
use STAKE_POOL_SIZE as an exact getProgramAccounts({ dataSize }) filter and
as a `data.length < SIZE` gate, so 408 would match zero live pools. They
flip with the coordinated stake + wrapper deploy.

Also correct specs/stake-parity.json to 408: the fixtures mirror
percolator-stake source, not the deployed program, so 392 was drift. The
deployed layout is pinned separately as a literal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 16 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 394bac2d-92e0-4560-a90f-378b02e19cb9

📥 Commits

Reviewing files that changed from the base of the PR and between 5b0aa77 and cc90dbb.

⛔ Files ignored due to path filters (3)
  • dist/index.js is excluded by !**/dist/**
  • dist/index.js.map is excluded by !**/dist/**, !**/*.map
  • dist/solana/stake.d.ts is excluded by !**/dist/**
📒 Files selected for processing (5)
  • CHANGELOG.md
  • specs/stake-parity.json
  • src/solana/stake.ts
  • test/parity-fixtures.test.ts
  • test/stake.test.ts

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.

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