Sickle Sol

Fees

This layer computes the protocol fee on a measured amount and transfers it from a vault to the fee collector.

Note: Every path:line below is relative to programs/sickle/src/.

Files

PathLinesPurpose
fees.rs1955The fee charge and its borrowed bundles, raw token-account readers, the mint-extension policy, and transfer-hook account helpers
state.rs21-28The FeeAction enum
state.rs824-839fee_bps_for and fee_amount on the global config
constants.rs15-22BPS_DENOMINATOR, MAX_FEE_BPS, FEE_ACTION_COUNT
venue/policy.rs1-75The fee-class matrix per position operation
venue/claim.rs60-73The four fee-class constants the claim runners use
events.rs287-293The FeeCharged event

Responsibilities

  • Name the six fee classes and index the per-class bps array.
  • Compute the fee as a floor of amount * bps / 10_000.
  • Transfer the fee from a Sickle vault to the collector's token account with the Sickle PDA signature.
  • Read a token account balance or a mint's decimals from raw bytes without an InterfaceAccount.
  • Check a raw fee-collector account against the configured collector and mint.
  • Decide which Token-2022 mint extensions a position may hold.
  • Read and forward a mint's transfer-hook accounts.
  • Select the token program that owns a mint.

Public items

fees.rs

ItemKindMeaning
SickleSignerpub structBorrows the Sickle PDA account and its owner/bump signer material. fees.rs:566-570
TransferLegpub structBorrows the source, collector, mint, token program, and hook accounts for one fee transfer. fees.rs:578-584
charge_fee_checkedpub fnCharges with a hand-built TransferChecked plus hook accounts. Returns the fee. fees.rs:620-673
assert_fee_collector_accountpub(crate) fnRequires a raw account to be a token account of the collector for one mint. fees.rs:157-164
token_account_amountpub(crate) fnReads amount from raw token-account bytes. fees.rs:183-186
assert_mint_extensions_for_custodypub(crate) fnApplies the mint-extension policy to a live mint. fees.rs:451-454
hook_account_metaspub(crate) fnCopies key, signer, and writable bits of forwarded hook accounts. fees.rs:481-490
take_hook_accountspub(crate) fnConsumes one mint's hook accounts from a remaining-account cursor. fees.rs:519-560
assert_token_program_for_mintpub(crate) fnRequires the token program to be executable and to own the mint. fees.rs:695-710
token_program_for_mintpub(crate) fnPicks the classic or Token-2022 program by mint owner. fees.rs:727-737
private helpersfnvalidate_collector_bytes 60, token_account_amount_from_bytes 139, mint_decimals_from_bytes 179, transfer_hook_extension_value_offset 243, transfer_hook_program_from_bytes 281, validate_mint_extensions_for_custody 328, transfer_checked_data 678
private constantsconstToken layout offsets at fees.rs:12-57, TLV offsets at fees.rs:205-219, hook seed and transfer tag at fees.rs:456-460. All literal, because the UI scrapes them; seven const-asserted against upstream, four against packed bytes

Items in other files

ItemKindMeaning
FeeActionenumHarvest, Compound, Rebalance, Exit, Deposit, Withdraw. state.rs:21-28
GlobalConfig.fee_bpsfield[u16; 6], indexed by FeeAction as usize. state.rs:847
GlobalConfig::fee_bps_forprivateReads one slot for fee_amount. state.rs:919-921
GlobalConfig::fee_amountfnFloor of amount * bps / 10_000 in u128. state.rs:926-933
GlobalConfig.fee_collectorfieldThe collector wallet. state.rs:845
set_feefnAdmin writes one slot, capped. instructions/admin/set_fee.rs:20-24
set_fee_collectorfnAdmin writes the collector. instructions/admin/set_fee_collector.rs:20-23
PositionOperationenumThe 8 position operations. venue/policy.rs:10-19
FeePolicystructprincipal, pool_fees, rewards as Option<FeeAction>. venue/policy.rs:25-29
position_fee_policyconst fnThe matrix. venue/policy.rs:32-87
COLLECT_FEES_ACTIONconstHarvest. venue/claim.rs:158-159
COLLECT_REWARDS_ACTIONconstHarvest. venue/claim.rs:160-161
HARVEST_FEE_ACTIONSconstpool fees Harvest, rewards Harvest. venue/claim.rs:162
COMPOUND_FEE_ACTIONSconstpool fees Compound, rewards Compound. venue/claim.rs:163
FeeChargedeventsickle, action, mint, amount, collector. events.rs:297-303

Limits

LimitValueSource
Fee denominator10 000 bpsconstants.rs:15
Maximum fee per class500 bpsconstants.rs:18, instructions/admin/set_fee.rs:21
Fee classes6constants.rs:22
Token account base length165 bytesfees.rs:37
Token account state byteoffset 108fees.rs:41
Token account amountoffset 64fees.rs:44
Mint base length82 bytesfees.rs:50
Mint decimals byteoffset 44fees.rs:54
Mint initialized byteoffset 45fees.rs:57
Mint extension TLV startoffset 166fees.rs:209
TransferHook extension value64 bytesfees.rs:219
TransferChecked data10 bytesfees.rs:678-684

Fee-class selection

The runners select a fee class per economic component from one matrix. venue/policy.rs:32-87

OperationPrincipalPool feesRewardsSelected at
CollectFeesnoneHarvestnonevenue/claim.rs:77-78,175
CollectRewardsnonenoneHarvestvenue/claim.rs:160-161
HarvestnoneHarvestHarvestvenue/claim.rs:162
CompoundnoneCompoundCompoundvenue/claim.rs:163
Decreasenonenonenonevenue/decrease_close.rs:24-29
ClosenonenoneHarvestvenue/decrease_close.rs:31-36
RebalanceRebalanceHarvestHarvestvenue/owner_rebalance.rs:456, venue/keeper_rebalance.rs:277
ExitExitHarvestHarvestvenue/keeper_exit.rs:291

Only jupiter_swap selects a class the matrix does not carry. Every Meteora row below reads its class from the matrix at compile time, through a const fn that cannot return a Result and so panics the build when the table stops supplying it.

HandlerClassLines
jupiter_swapDeposit on amount_in, Withdraw on the output deltainstructions/jupiter/swap.rs:125-144,155-226,461,500
meteora_claim_feesCOLLECT_FEES_ACTIONinstructions/meteora/claim_fees.rs:265,278,302,310
meteora_claim_rewardCOLLECT_REWARDS_ACTIONinstructions/meteora/claim_reward.rs:255,275,282
meteora_rebalance_forRebalance on principal from the matrix, HARVEST_FEE_ACTIONS on yieldinstructions/meteora/rebalance_for.rs:41
meteora_exit_forExit on principal from the matrix, HARVEST_FEE_ACTIONS on yieldinstructions/meteora/exit_for.rs:37
meteora_harvest_for, meteora_compound_forYieldFeeActions passed by the callerinstructions/meteora/yield_adapter.rs:324,337,389, instructions/meteora/compound_for.rs:154

How a call flows through it

The representative call is the owner's Orca collect.

  1. The claim runner selects COLLECT_FEES_ACTION, which is Harvest. venue/claim.rs:514
  2. The Orca adapter calls collect_fees_and_charge with that class. venue/adapters/orca.rs:355
  3. The helper requires each token program to own its mint. venue/adapters/orca_flows.rs:989-990
  4. take_hook_accounts reads each mint's transfer-hook program from the mint bytes. With no live hook it requires a declared length of 0. With a hook it requires the first forwarded account to be that program and the second to be the extra-account-metas PDA. venue/adapters/orca_flows.rs:359-402, fees.rs:519-560
  5. The helper records both vault balances. venue/adapters/orca_flows.rs:999-1002
  6. invoke_orca_fee_collect runs the Orca collect CPI. The caller reloads both vaults and computes each collected delta. venue/adapters/orca_flows.rs:881-915,1003-1009
  7. charge_fee_checked computes fee_amount(Harvest, collected_a). A zero fee returns 0 with no CPI. fees.rs:627-630, state.rs:926-933
  8. It reads the mint's decimals from raw bytes. fees.rs:632-635
  9. It builds a TransferChecked instruction with the vault, mint, collector, and Sickle PDA, appends the hook account metas, and invokes it with the Sickle seeds. fees.rs:650-671
  10. It returns the fee. The caller emits FeeCharged with that amount. fees.rs:672, instructions/orca/collect_fees.rs:143

The collector account for each side reaches charge_fee_checked as a raw AccountInfo. The Raydium adapter and meteora_harvest_for check it with assert_fee_collector_account. venue/adapters/raydium.rs:515-531, instructions/meteora/yield_adapter.rs:152

Invariants the layer owns

  1. No fee class exceeds 500 bps. set_fee returns FeeTooHigh. instructions/admin/set_fee.rs:21
  2. The fee is the floor of amount * bps / 10_000, computed in u128. The only error is MathOverflow, which the cap makes unreachable. state.rs:926-933
  3. A zero fee moves nothing. charge_fee_checked returns 0 before any CPI. fees.rs:627-630
  4. The fee leaves the vault under the Sickle PDA signature only. charge_fee_checked signs with [b"sickle", owner, bump]. fees.rs:637
  5. A raw collector account is an initialized token account of the classic or Token-2022 program. It has the base length or the account type byte. Its mint is the expected mint and its owner is the configured collector. The errors are InvalidAccountOwner and InvalidFeeCollectorMint. fees.rs:123-137
  6. A raw balance read accepts the same two owners and the same shape. The errors are InvalidAccountOwner and InvalidProtocolAccountData. fees.rs:173-180
  7. A mint that enters a position carries only the allowed extensions. A TransferHook extension returns UnsupportedTransferHookMint, even when its program id is unset. Any other listed or unknown extension returns UnsupportedMintExtension. Malformed TLV returns InvalidProtocolAccountData. fees.rs:360-445
  8. Forwarded hook accounts start with the mint's hook program and, when more than 1 is declared, the extra-account-metas PDA of that program and mint. The error is TransferHookAccountsMissing. fees.rs:519-560
  9. A token program passed beside a mint is executable and owns the mint. The error is InvalidAccountOwner. fees.rs:695-710

Tests

LocationCovers
fees.rs:773-834The four byte positions, against bytes the upstream Pack implementations produced
fees.rs:848-941Both raw token-account readers: the shared shape, and the exact code each gives a malformed one
fees.rs:950-1007A Token-2022 mint serialized by the upstream extension writer: the TLV constants, the hook scan, the custody refusal
fees.rs:1027-1193Collector bytes: classic, Token-2022 base and extended, foreign owner, wrong length, mint-as-account, wrong mint, wrong owner, uninitialized, short
fees.rs:1068-1094, fees.rs:1097-1130token_account_amount_from_bytes reads both programs and rejects five malformed shapes
fees.rs:1203-1215, fees.rs:1612-1622Mint decimals for both programs, and three malformed mints
fees.rs:1218-1296, fees.rs:1299-1363token_program_for_mint on both programs and a foreign owner, and assert_token_program_for_mint
fees.rs:1392-1404TransferChecked data bytes for two amounts
fees.rs:1407-1609Hook program read: classic, bare, unset, set, behind other extensions, truncated, overrun, account-as-mint
fees.rs:1448-1470, fees.rs:1511-1587Mint-extension policy: dormant hook rejected, 12 allowed extensions, 14 rejected, unknown, malformed
state.rs:1219-1248fee_amount floors to the unit, is zero when unset, indexes Deposit and Withdraw, no overflow
state.rs:3097-3181Property tests: fee never exceeds the amount, is the exact floor, is monotonic, never overflows
venue/policy.rs:90-158Every row of the matrix
fees.rs:1693-1722hook_account_metas copies both privilege bits
fees.rs:1732-1864take_hook_accounts: the head pin, the validation PDA, the declared length, the cursor
fees.rs:1878-1954The four shapes the two TLV walks answer differently
venue/claim.rs:816-858The four constants and the yield-fee grouping

Gaps

GapEvidence
charge_fee_checked has no Rust testfees.rs:620-673. It needs a CPI, so only Surfpool lifecycles cover it.

Simplification and audit notes

All 13 notes were resolved in #232, #234, #236, #237, and #238 and are listed under "Resolved" below. The list carries a prettier-ignore comment so the original note numbers remain stable instead of being renumbered by prettier.

Resolved

  1. L09-R001 — charge_fee has no caller in programs/sickle/src. Its doc comment names a pending Orca *_v2 migration as the reason it remains, and the Orca legs call charge_fee_checked. The comment no longer matches the code. Resolved in #236 by deleting it, which is what the code itself asked for: its doc stated the retirement criterion -- "retired once the last classic caller moves over" -- and that criterion had already fired. The sweep, with its denominator: all 695 tracked files, every extension (309 .ts, 138 .rs, 85 .svelte, 44 .md, 32 .json, 18 .sh, 12 .stderr, 10 .py, 8 .yml, 7 .toml, 6 .mjs, and the rest), plus all 130 .rs under programs/ including untracked files. git grep -w charge_fee returned the definition, two doc cross-references, and prose. Zero call sites. No workspace crate depends on sickle: the members are programs/*, and mock-transfer-hook depends only on anchor-lang. It was pub rather than pub(crate), which is why nothing warned. Deleting it cannot change behaviour, because nothing called it -- the argument is the grep, not the artifact. The .so does differ, for the three require! origins in assert_token_program_for_mint that shift up behind it, which is what a differing artifact means here and why it proves nothing on its own. The advertising went with the mechanism. Five live claims named the deleted helper as the one that charges fees -- CLAUDE.md, README.md, RELEASE_RUNBOOK.md's collector-ATA trap note, scripts/builder/fees.ts, and tests/mainnet-acceptance.ts -- and all five now name charge_fee_checked. TODO.md keeps its references: it is a dated plan log and those lines record what was true when they were written.

  2. L09-R002 — The doc comment describing take_hook_accounts runs into the doc comment describing hook_account_metas, and both attach to hook_account_metas. take_hook_accounts has no doc comment. Resolved in #234, exactly as written -- one of the few notes in this layer that needed no reinterpretation. The block that describes taking a hook set from the remaining-account cursor now sits above take_hook_accounts, and hook_account_metas keeps only its own. The same bytes, reordered: no wording changed, and the two functions are unmoved, so every line below them keeps its number.

  3. L09-R003 — validate_collector_bytes and token_account_amount_from_bytes repeat the same owner, length, state-byte, and type-byte checks with different error codes. InvalidFeeCollectorMint in one, InvalidProtocolAccountData in the other. Resolved in #234 as a drift defect, because nothing held the two copies together: the tests asserted only is_err() on each, so a byte-layout fix to one would leave the other's tests green. Both now call require_token_account_shape, which takes the malformed-shape error as a PARAMETER. That difference is load-bearing rather than incidental -- a caller pinning a fee-collector slot and a caller measuring a balance report different things -- so it is preserved rather than merged away. InvalidAccountOwner, the one code they already shared, stays fixed inside the helper. ONE OBSERVABLE CHANGE, and it is not a code change. Anchor writes each check's source position into its error origin, and the error path logs ProgramError thrown in <file>:<line>. Both readers now report the helper's line rather than their own, so that log line no longer says which of the two failed. The error CODE still does, which is why the test pins codes rather than origins. The condition on the length-and-state check is also written the other way round -- len < BASE || state == 0 where it was !(len >= BASE && state != 0) -- which is the same predicate by De Morgan and short-circuits identically, so data[TOKEN_ACCOUNT_STATE_OFFSET] is still only indexed once the length is known to cover it. Two things the note does not say, both found by doing it. The test that makes this verifiable was written and committed BEFORE the extraction, so it is on record as having run against the unshared code; it pins every shape against both readers' exact codes, and was confirmed by swapping each reader's code for the other's and by dropping each one's type-byte check. And require! cannot carry the error as a variable: its single-token-tree arm captures a bare identifier as crate::ErrorCode::<ident>, so the three shared checks spell their branches out, which is what lets the code be a parameter at all.

  4. L09-R004 — charge_fee_checked returns the debited amount, not the amount the collector received. Its doc says the collector receives the amount net of a Token-2022 transfer fee. Callers emit FeeCharged.amount from that return value. Resolved in #236 as a DOC fix, because the behaviour was already decided: theme 7's "Gross versus net fee amount", decided 2026-09-10, keeps the gross debit, and the reconciliation rule already sits on FeeCharged where a consumer reads it. What the function returns is unchanged. The note reads as though the doc contradicted the code. It did not -- it already said both halves correctly. Two narrower things were wrong with it. It grounded the choice in "(owner-confirmed)", an appeal no reader can check, where a dated decision with a rationale exists; it now cites that decision. And it cross-referenced [charge_fee] twice, which historical note 1 deletes. A third staleness was in the same doc and in no note: it said transfer-hook mints "are out of scope here: this CPI passes only the four base accounts". The function grew a hook_accounts parameter and forwards them. The doc now says so, and says what that forwarding is for -- defense in depth for positions funded before the mint-extension screen existed, not an admission path.

  5. L09-R005 — The Meteora claim handlers select fee classes as literals. The CLMM runners read them from position_fee_policy, and the Meteora rebalance and exit handlers now do too. The same rule still lives in two places for the two claim handlers. Resolved in #237, and the note cited three sites where there were NINE. It named the two charge sites in meteora_claim_fees and the one in meteora_claim_reward; it did not name the four EVENT action fields in those same handlers, which report the class that was charged and were equally literal. Nor did it name meteora_rebalance_for and meteora_exit_for, whose charge sites already read the table through REBALANCE_PRINCIPAL and EXIT_PRINCIPAL and whose event fields, nine lines away in the same loop, still said FeeAction::Rebalance and FeeAction::Exit beside a derived HARVEST_FEE_ACTIONS.pool_fees. That is the earlier fix leaving its own event field behind. All nine now derive. TWO CHECKS, DOING TWO DIFFERENT JOBS. Neither is worth much alone. The first proves the CHAIN EXISTS: removing an action from position_fee_policy fails the build. Verified for all four table entries these sites depend on, and the failure location is not uniform, which is worth knowing before relying on it. Dropping Rebalance.principal or Exit.principal reports inside the handler itself (instructions/meteora/rebalance_for.rs:38, instructions/meteora/exit_for.rs:35) because those handlers define their own const. Dropping CollectFees.pool_fees or CollectRewards.rewards reports at venue/claim.rs:159 and :161, where the shared const is defined, because the claim handlers consume COLLECT_FEES_ACTION and COLLECT_REWARDS_ACTION rather than redeclaring the derivation. The second proves each site is ON that chain, and it is the one that distinguishes a derived site from a stale one. A build break does NOT: with eight sites derived and one still holding its literal, the const still fails to evaluate through the other eight, so the stale site is invisible to exactly the check meant to find it. The sweep instead: all 17 files in instructions/meteora/, zero FeeAction:: occurrences outside #[cfg(test)] and outside comments. claim_fees.rs and claim_reward.rs hold none at all; rebalance_for.rs and exit_for.rs hold four each, every one below the #[cfg(test)] at line 654 and line 517 respectively. WHAT NEITHER CHECK COVERS, and it is the gap worth stating rather than papering over: no test asserts that a Meteora handler's EMITTED action equals the action it CHARGED. Both now read one const, so they cannot disagree today, and a test written now would assert that a value equals itself. Its real value would be catching a future edit that reintroduces a literal. #237 checked that with a manual source sweep. Its review caught the stronger claim that the sweep runs on every push: no committed workflow, development checker, or hook repeats it, so a future literal with today's value would pass the current automated checks. Automating the assertion properly needs the event mapping lifted out of these long handlers into a pure function first; that is the same missing-mapper shape as the parameter-count work, and it is a separate contract, not a rider on this one.

  6. L09-R006 — charge_fee_checked builds the TransferChecked instruction by hand while finalize_intent uses the anchor-spl wrapper for the exit sweep. The two transfer paths differ. Resolved in #234 with no code change: the difference is real and correct, and what was missing was the reason at the second of the two places. The fee leg already explained why it hand-rolls -- the wrapper never reads CpiContext::remaining_accounts, so it cannot carry a hooked mint's accounts and every non-zero fee would fail. The sweep said nothing about why it may use the wrapper anyway. Both ends now do. THE FIRST VERSION OF THAT EXPLANATION OVERCLAIMED, and the reviewer caught it. It said a hook-capable mint cannot reach the sweep because the funding screen refuses it. That holds for the two POOL mints and for nothing else. An exit TARGET is not screened by anything: validate_complete_set requires only that it be reachable over the signed swap edges and does not require it to be a pool mint, snapshot_extra_baselines checks only its vault's mint identity and owner, and a target that is a swap output never passes through an increase, so assert_mint_extensions_for_custody never sees it. Naming a hook-capable target is therefore possible. It fails CLOSED -- the token program rejects a hooked transfer arriving without its extra accounts and the finalizer reverts -- so the cost is a bricked exit for that target rather than a custody or authorization hole, and the mint was the authorization signer's choice rather than the keeper's. It is a real limitation even so, and the comment now states it as one instead of claiming a guarantee that covers only half the entries.

  7. L09-R007 — transfer_hook_extension_value_offset and validate_mint_extensions_for_custody both walk the same TLV. The first walk stops when fewer than 4 bytes remain. The second walk returns Ok on 1 trailing byte and InvalidProtocolAccountData on 2 or 3 trailing bytes that name a non-zero extension. Resolved in this pass, and the note understated it: the walks disagree on FOUR shapes, not one, and the fourth is the one that matters. The second walk stops at an Uninitialized entry and the first scans past it, so a hook sitting behind padding is reported by one and admitted by the other. Every disagreement is deliberate and safe only in its own direction, and none of them was tested or written down. Merging the walks -- the obvious tidy-up, and the one this note reads as proposing -- would change behaviour on all four, so it was not done. Instead each shape is pinned by the_two_tlv_walks_disagree_on_these_exact_shapes and the reasoning sits on transfer_hook_extension_value_offset, at the point of temptation. The safety argument for the load-bearing case: stopping at Uninitialized is what the token program's own get_tlv_indices does, so a hook behind padding is never invoked and admitting the mint is correct, while scanning past it can only over-report a hook -- a fail-closed TransferHookAccountsMissing, never a missed live one. Verified by making each walk adopt the other's rule and watching the test fail.

  8. L09-R008 — token_program_for_mint returns the classic program for any owner other than Token-2022, including a foreign owner. Callers rely on a separate check to reject the foreign owner. Resolved in this pass with no behaviour change. The claim holds and is not a defect: the function returns a reference and so has no error to return, and every call site guards the same mint a step later -- assert_token_program_for_mint on the Orca reward path, and mint_decimals_from_bytes inside charge_fee_checked on the Raydium, Jupiter and Meteora paths. Both reject a foreign owner with InvalidAccountOwner. What was actually missing was evidence: the selector's own test covered the classic and Token-2022 cases only, so the foreign-owner behaviour this note describes was untested in both halves. The case and both guards are now in token_program_for_mint_selects_by_owner, and the containment is stated on the function rather than left to be rediscovered.

  9. L09-R009 — The constants re-declare token layout literals. Two are asserted against anchor-spl. The other six have no such assertion. Resolved in this pass by reading the note precisely: the complaint is that only two of the eleven constants are CHECKED against anything, not that the literals exist. Every literal stays, and each now carries its check. The seven whose value an upstream type names are asserted against it at COMPILE time, so a disagreement is a build failure: TOKEN_ACCOUNT_BASE_LEN, MINT_BASE_LEN, TOKEN_2022_ACCOUNT_TYPE, TOKEN_2022_MINT_TYPE, MINT_EXTENSION_TLV_OFFSET, EXTENSION_TYPE_TRANSFER_HOOK and TRANSFER_HOOK_EXTENSION_LEN. The four that are byte POSITIONS no upstream constant names are asserted against the bytes a real spl_token_2022::state::{Account, Mint} packs to. That second oracle is the point: the fixtures already in this module write their fields at the very offsets the code under test reads, so a wrong offset agreed with itself and passed. It caught what no existing test could -- pointing EXTENSION_TYPE_TRANSFER_HOOK at the wrong extension number left every hand-built fixture test green. Verified by breaking all eleven values one at a time: the seven upstream-named ones fail the build, the four positional ones fail a test. THE LITERALS ARE LOAD-BEARING AND MUST NOT BECOME EXPRESSIONS. management-ui/src/lib/dlmm/mint-extensions.test.ts reads this file as TEXT and regex-matches const <NAME>: usize = <value>; for five of them; that scrape is the only thing holding the UI's own TypeScript mint screen to the same numbers. A first attempt at this note replaced the literals with the upstream expressions and broke it, which CI caught. A derived spelling the UI cannot evaluate would pin nothing, so a literal plus an assertion is strictly more checking than either alone. The note also recorded a claim the file made and the compiler does not: TOKEN_ACCOUNT_BASE_LEN's comment said it was a literal because anchor_spl::token::TokenAccount::LEN could not be used in a const context. It is an inherent associated const and it can -- the const assertion added here uses it. The literal has a reason, just a different one, and the comment now gives that reason instead.

  10. L09-R010 — EXTENSION_TYPE_TRANSFER_HOOK is the literal 14, while validate_mint_extensions_for_custody converts the same number through ExtensionType::try_from. The two walks name the extension differently. Resolved in this pass with historical note 12. The literal stays, because the UI scrapes this one too, and a const assertion against ExtensionType::TransferHook as u16 now sits beside it. The two walks can no longer disagree about which number it is: if they did, the build fails.

  11. L09-R011 — fees.rs mixes three concerns. Lines 50-189 read token and mint bytes. Lines 191-422 decide the mint-extension policy. Lines 424-528 handle transfer-hook accounts. Only lines 530-664 charge a fee. The file name describes one quarter of its content. Resolved in #238 as a wrong abstraction and a measured follow-on, not by splitting the file. Moving the four concerns would leave the repeated call shape untouched: charge_fee_checked takes 11 parameters at fees.rs:597-628, and it has exactly 19 call sites -- 3 in venue/adapters/raydium_flows.rs:226-330, 4 in venue/adapters/orca_flows.rs:659-670,994-1018,1215-1226, 10 across instructions/meteora/claim_fees.rs:257-281, instructions/meteora/claim_reward.rs:247-258, instructions/meteora/yield_adapter.rs:318-394, instructions/meteora/rebalance_for.rs:477-501, and instructions/meteora/exit_for.rs:357-381, and 2 in instructions/jupiter/swap.rs:178-190,213-225. Eight of the 11 roles form the same two bundles at every site: the Sickle signer triple (sickle, owner, bump) and the transfer-leg quintuple (from, to, mint, token program, hook accounts). The follow-on is implemented in this pass: charge_fee_checked now takes five parameters at fees.rs:620-626, and all 19 call sites pass borrowed &SickleSigner and &TransferLeg values. The fields inside both bundles are borrowed too. An initial version owned cloned AccountInfo handles; that reduced the call boundary but enlarged all four Meteora claim_all_yield frames enough for the SBF verifier to report frame overwrites. Borrowing the handles removed that storage, and the locked SBF build was silent after the Meteora and final Jupiter checkpoints. That is the simplification the original file-split note was reaching for while keeping the 4 KB SBF frame limit observable during the change.

  12. L09-R012 — jupiter_swap selects Deposit and Withdraw at instructions/jupiter/swap.rs:125-144. Those two classes appear nowhere in venue/policy.rs. The matrix is not the single source of fee classes. Resolved in #238 with no code change: the claim is true and is not a defect. PositionOperation and position_fee_policy explicitly describe position operations at venue/policy.rs:10-32; a Jupiter swap leg is a different axis. Its caller-declared context is fail-closed instead: the match at instructions/jupiter/swap.rs:125-144 accepts only Deposit or Withdraw and returns InvalidSwapFeeAction for every other class. The position matrix remains exhaustive for the domain it names.

  13. L09-R013 — assert_fee_collector_account is called by the Raydium adapter at venue/adapters/raydium.rs:501-510,669-678,942-951 and by instructions/meteora/yield_adapter.rs:151. The Orca handlers do not call it. The check on a collector account has a different owner per venue. Resolved in #238 with no code change: the invariant has two enforcement mechanisms because the accounts have two representations. Orca declares its collector slots as typed token-interface accounts with Anchor token::mint and token::authority = global_config.fee_collector constraints; instructions/orca/collect_fees.rs:121-135 is the smallest example, and the compound, harvest, rebalance, and exit structs carry the same attributes. The Raydium and Meteora paths centralize validation in their adapters because some instruction structs expose dynamic unchecked accounts; they call assert_fee_collector_account before use, including as a redundant defense where a particular Raydium struct is already typed. Theme 4's decision about typing keeper accounts is related evidence, not a decision about making Orca duplicate this helper. The collector rule is enforced on both paths today; only the mechanism and error surface differ.

The line citations in historical notes 5, 11, 12, and 13 refer to the file as it was before those changes.

Design note

fee_amount mirrors the EVM FeesLib.chargeFee rounding and the 500 bps cap. The Deposit and Withdraw classes replace the EVM deposit and withdraw entrypoints, which Solana does not have. See the CLAUDE.md rows "SickleRegistry.MAX_FEE = 500", "FeesLib.chargeFee", and "Zap-swap fee basis".

Source: docs/dev/layers/09-fees.md in vfat-io/sickle-programs, vendored here by its sync workflow. Edit the markdown there; this page is rebuilt from it on deploy.