fix(nft): make GetPositionValue actually fail-CLOSED on stale state (#180) - #181
Conversation
The module doc and README stated that GetPositionValue is fail-CLOSED on "stale/slot-reuse/no-active-leg". Slot-reuse and no-active-leg returned Err; staleness was never checked. The handler read none of `leg.stale`, `leg.b_stale`, `stale_state`, `b_stale_state`, `liquidation_lock`, `resolved_payout_receipt.present` or `close_progress`, emitted none of them, and emitted no `status` line on the success path — so a consumer had neither an error to catch nor a field to inspect. The program therefore contradicted itself on the same portfolio: the transfer hook refused to move an NFT that GetPositionValue reported to marketplaces and lending protocols as a healthy active leg with full economics. Before this change the hook's gate and the valuation disagreed on exactly seven states. Route the decision through `leg_transfer_gate` — documented as the "single consolidated gate" for the hook and the wrapper's B-3 — instead of reimplementing one of its five checks. Each verdict emits its own `status=<reason>` and returns the matching error; `no_active_leg` keeps `LegNotActive` so existing consumers are unaffected. Ordering matters and follows the hook (verify_bound_leg then transfer_gate_check): the slot-reuse check runs BEFORE the gate verdict is applied. Otherwise a portfolio that is both slot-reused and transiently stale would report the transient reason, masking a terminal signal that routes the holder to EmergencyBurn behind one that says "retry later". Blocked positions still report their economics, but under a separate `POSITION_BLOCKED_V16:` prefix. Emitting them under the existing prefix would let a parser that ignores `err` keep reading a real number off a liquidation-locked position — the very defect this closes. Withholding them entirely would be worse than it sounds: the engine sets `leg.b_stale` on any multi-chunk backing settlement, so that state is ordinary crank-paced operation, and `resolved` is terminal and carries the final settled value. The wrapper records the same principle for this gate — `UnwrapEscrowedPortfolio` is "deliberately NOT gated on active-leg / resolved_payout_receipt / liquidation_lock / stale / close-progress" because "gating on those would strand funds". The `status=` mapping is extracted as a pure `gate_status` and unit-tested exhaustively, because `msg!` output cannot be captured off-chain on this pin (solana-msg's non-BPF `sol_log` is a bare `println!` that bypasses `program_stubs`) and the four blocked states share one error code, so the string is the only thing distinguishing them. Also: adds `status=ok` to the success path, splits the compound `slot_reuse_detected` status line into one key per line, and documents the whole log contract — status vocabulary, prefixes, the simulateTransaction batching caveat — in the README, where nothing previously enumerated it. Closes dcccrypto#180 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review. 📝 WalkthroughWalkthrough
ChangesFail-closed valuation
Estimated code review effort: 4 (Complex) | ~45 minutes Merge Risk: ⚪ Minimal · up to The change makes valuation fail closed for blocked or stale positions while preserving read-only behavior and existing ownership checks. No actionable merge-blocking risk remains after normal checks and review. Suggested reviewers: Sequence Diagram(s)sequenceDiagram
participant GetPositionValue
participant leg_transfer_gate
participant Portfolio
participant PositionLogs
GetPositionValue->>Portfolio: read portfolio and NFT state
GetPositionValue->>leg_transfer_gate: evaluate transferability
leg_transfer_gate-->>GetPositionValue: return status and error
GetPositionValue->>PositionLogs: emit blocked or healthy valuation fields
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Title checkExplanation The title clearly describes the primary change: making GetPositionValue fail closed for stale state. It is related to the broader gate-alignment work, although it does not list every newly blocked condition. Full details: Linked Issues checkExplanation The changes satisfy issue Full details: Docstring CoverageExplanation Docstring coverage is 70.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 20 functions across 3 files. (1 skipped: 1 unsupported.)
✨ 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 |
Closes #180.
Problem
GetPositionValuedocumented itself as fail-CLOSED on stale state but read no staleness field.no_active_legandslot_reusereturnedErr; nothing checkedleg.stale,leg.b_stale,stale_state,b_stale_state,liquidation_lock,resolved_payout_receipt.presentorclose_progress— none were emitted either, and the success path emitted nostatusline, so a consumer had neither an error to catch nor a field to inspect.The program contradicted itself on the same portfolio: the transfer hook refused to move an NFT that this instruction reported to marketplaces and lending protocols as a healthy active leg with full economics. Before this change the hook's gate and the valuation disagreed on exactly seven portfolio states.
Fix
Route the decision through
leg_transfer_gate— documented atslab_types_v16.rs:559-561as the "single consolidated gate" for the hook and the wrapper's B-3 — rather than reimplementing one of its five checks. Each verdict emits its ownstatus=<reason>and returns the matching error.no_active_legkeepsLegNotActive(22), so existing consumers of that path are unaffected.Ordering follows the hook (
verify_bound_legthentransfer_gate_check): the slot-reuse check runs before the gate verdict is applied. This matters — a portfolio that is both slot-reused and transiently stale must report the slot reuse, becauseMarketIdMismatchis terminal and routes the holder toEmergencyBurn, whileb_staleis transient and merely says "retry later". Reporting the transient one would send a holder into an indefinite wait.slot_reuse_outranks_a_transient_stale_flagpins this.Blocked positions still report their economics — under a separate
POSITION_BLOCKED_V16:prefix. This is the part I'd most like scrutinised, so the reasoning in full:POSITION_VALUE_V16:prefix would let a parser that ignoreserrkeep reading a real number off a liquidation-locked position, which is precisely the defect being closed. A flag next to the data does not help a reader who was never reading flags.leg.b_staleon any multi-chunk backing settlement (percolator/src/v16.rs:10221), so that state is ordinary crank-paced operation, not an exception — going dark would blank pricing across a market every time it takes losses. Andresolvedis terminal and carries the position's final settled value, which is exactly what a lender needs to close out.UnwrapEscrowedPortfoliois "deliberately NOT gated on active-leg / resolved_payout_receipt / liquidation_lock / stale / close-progress" because "gating on those would strand funds" (v16_program.rs:16548).The split gives both: a parser scanning the well-known prefix fails closed by construction, and distressed pricing is available only to a caller who opts in deliberately.
slot_reuse_detectedis the sole case emitting no economics — its fields would describe a different position instance entirely.Verification
gate_statusmapping and 6 integration tests.cargo clippy --all-targets -- -D warningsclean;cargo build-sbf --tools-version v1.52succeeds.On testing the log vocabulary. A reviewer showed my first version's status strings were mutation-provably untested — replacing all four with
"WRONG", or deleting the emissions outright, left every test green.msg!output cannot be captured off-chain on this pin:solana-msg's non-BPFsol_logis a bareprintln!that bypassesprogram_stubs, soSyscallStubsnever sees it. I therefore extracted the mapping as a purepub(crate) fn gate_statusand unit-tested it exhaustively — the vocabulary is pinned, every string is asserted distinct, and onlyTransferableis allowed a non-error. That closes the gap the mutation exposed; a rename or copy-paste now fails a test.Also included
status=okon the success path. Previously health could only be inferred from the absence of a key — indistinguishable from a truncated log or a parser bug.slot_reuse_detectedsplit into one key per line; it previously packedmarket_id_at_mint=andcurrent_market_id=onto the status line, sostatusparsed as a compound value.valuation.rs,instruction.rs(the tag-3 doc comment, which is what an integrator reads first), andREADME.md.status=vocabulary was documented nowhere, and a search confirms there is no first-party SDK or TS client — the README plus the log prefix is the entire integration surface. The section lists every status with its meaning, recommended action and error code, plus the prefix split, thesimulateTransactionlogs-survive-errbehaviour and its exceptions, the 10 kB log cap, and the batching caveat below.Judgement calls left to you
Three reviewers disagreed on the consumer-facing shape; these are genuinely your call, not mine to assume:
GetPositionValuecalls into one simulation means one blocked position suppresses every call after it. The distinct-prefix design does not fix that. A backward-compatible alternative exists: tag 3 currently rejects trailing bytes, so an optional mode byte could offer an explicitly-requested advisory read that returnsOkwith a mandatorystatus=. That is an ABI extension, so I did not take it unilaterally.TransferBlocked(24). Two reviewers wanted distinct codes (the enum is documented append-only, with room); one argued against, since diverging fromtransfer_gate_check's mapping would reintroduce the two-implementations problem this change removes. I kept them shared and put the distinction in the status string, which is this instruction's real API.leg_staleandportfolio_locked_or_staleare mechanicalLegTransferGatevariant names. A reviewer arguedsettlement_pendingand a split intoliquidation_locked/account_settlement_pendingwould read better to an outside integrator. Splitting the latter means reading the underlying flags directly rather than through the gate, which is the coupling this change removes — andliquidation_lockis never set true anywhere in the engine today, so the split has little practical value yet.0.1.0.resolvedandclose_in_progressare genuinely new fail-closed conditions rather than the code catching up to documentation, so a version bump and a devnet-first rollout look warranted.🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
Documentation
GetPositionValuedocumentation with status values, error behavior, simulation details, batching caveats, and log limitations.Tests