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
| Path | Lines | Purpose |
|---|---|---|
fees.rs | 1955 | The fee charge and its borrowed bundles, raw token-account readers, the mint-extension policy, and transfer-hook account helpers |
state.rs | 21-28 | The FeeAction enum |
state.rs | 824-839 | fee_bps_for and fee_amount on the global config |
constants.rs | 15-22 | BPS_DENOMINATOR, MAX_FEE_BPS, FEE_ACTION_COUNT |
venue/policy.rs | 1-75 | The fee-class matrix per position operation |
venue/claim.rs | 60-73 | The four fee-class constants the claim runners use |
events.rs | 287-293 | The 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
| Item | Kind | Meaning |
|---|---|---|
SickleSigner | pub struct | Borrows the Sickle PDA account and its owner/bump signer material. fees.rs:566-570 |
TransferLeg | pub struct | Borrows the source, collector, mint, token program, and hook accounts for one fee transfer. fees.rs:578-584 |
charge_fee_checked | pub fn | Charges with a hand-built TransferChecked plus hook accounts. Returns the fee. fees.rs:620-673 |
assert_fee_collector_account | pub(crate) fn | Requires a raw account to be a token account of the collector for one mint. fees.rs:157-164 |
token_account_amount | pub(crate) fn | Reads amount from raw token-account bytes. fees.rs:183-186 |
assert_mint_extensions_for_custody | pub(crate) fn | Applies the mint-extension policy to a live mint. fees.rs:451-454 |
hook_account_metas | pub(crate) fn | Copies key, signer, and writable bits of forwarded hook accounts. fees.rs:481-490 |
take_hook_accounts | pub(crate) fn | Consumes one mint's hook accounts from a remaining-account cursor. fees.rs:519-560 |
assert_token_program_for_mint | pub(crate) fn | Requires the token program to be executable and to own the mint. fees.rs:695-710 |
token_program_for_mint | pub(crate) fn | Picks the classic or Token-2022 program by mint owner. fees.rs:727-737 |
| private helpers | fn | validate_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 constants | const | Token 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
| Item | Kind | Meaning |
|---|---|---|
FeeAction | enum | Harvest, Compound, Rebalance, Exit, Deposit, Withdraw. state.rs:21-28 |
GlobalConfig.fee_bps | field | [u16; 6], indexed by FeeAction as usize. state.rs:847 |
GlobalConfig::fee_bps_for | private | Reads one slot for fee_amount. state.rs:919-921 |
GlobalConfig::fee_amount | fn | Floor of amount * bps / 10_000 in u128. state.rs:926-933 |
GlobalConfig.fee_collector | field | The collector wallet. state.rs:845 |
set_fee | fn | Admin writes one slot, capped. instructions/admin/set_fee.rs:20-24 |
set_fee_collector | fn | Admin writes the collector. instructions/admin/set_fee_collector.rs:20-23 |
PositionOperation | enum | The 8 position operations. venue/policy.rs:10-19 |
FeePolicy | struct | principal, pool_fees, rewards as Option<FeeAction>. venue/policy.rs:25-29 |
position_fee_policy | const fn | The matrix. venue/policy.rs:32-87 |
COLLECT_FEES_ACTION | const | Harvest. venue/claim.rs:158-159 |
COLLECT_REWARDS_ACTION | const | Harvest. venue/claim.rs:160-161 |
HARVEST_FEE_ACTIONS | const | pool fees Harvest, rewards Harvest. venue/claim.rs:162 |
COMPOUND_FEE_ACTIONS | const | pool fees Compound, rewards Compound. venue/claim.rs:163 |
FeeCharged | event | sickle, action, mint, amount, collector. events.rs:297-303 |
Limits
| Limit | Value | Source |
|---|---|---|
| Fee denominator | 10 000 bps | constants.rs:15 |
| Maximum fee per class | 500 bps | constants.rs:18, instructions/admin/set_fee.rs:21 |
| Fee classes | 6 | constants.rs:22 |
| Token account base length | 165 bytes | fees.rs:37 |
| Token account state byte | offset 108 | fees.rs:41 |
| Token account amount | offset 64 | fees.rs:44 |
| Mint base length | 82 bytes | fees.rs:50 |
| Mint decimals byte | offset 44 | fees.rs:54 |
| Mint initialized byte | offset 45 | fees.rs:57 |
| Mint extension TLV start | offset 166 | fees.rs:209 |
TransferHook extension value | 64 bytes | fees.rs:219 |
TransferChecked data | 10 bytes | fees.rs:678-684 |
Fee-class selection
The runners select a fee class per economic component from one matrix.
venue/policy.rs:32-87
| Operation | Principal | Pool fees | Rewards | Selected at |
|---|---|---|---|---|
CollectFees | none | Harvest | none | venue/claim.rs:77-78,175 |
CollectRewards | none | none | Harvest | venue/claim.rs:160-161 |
Harvest | none | Harvest | Harvest | venue/claim.rs:162 |
Compound | none | Compound | Compound | venue/claim.rs:163 |
Decrease | none | none | none | venue/decrease_close.rs:24-29 |
Close | none | none | Harvest | venue/decrease_close.rs:31-36 |
Rebalance | Rebalance | Harvest | Harvest | venue/owner_rebalance.rs:456, venue/keeper_rebalance.rs:277 |
Exit | Exit | Harvest | Harvest | venue/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.
| Handler | Class | Lines |
|---|---|---|
jupiter_swap | Deposit on amount_in, Withdraw on the output delta | instructions/jupiter/swap.rs:125-144,155-226,461,500 |
meteora_claim_fees | COLLECT_FEES_ACTION | instructions/meteora/claim_fees.rs:265,278,302,310 |
meteora_claim_reward | COLLECT_REWARDS_ACTION | instructions/meteora/claim_reward.rs:255,275,282 |
meteora_rebalance_for | Rebalance on principal from the matrix, HARVEST_FEE_ACTIONS on yield | instructions/meteora/rebalance_for.rs:41 |
meteora_exit_for | Exit on principal from the matrix, HARVEST_FEE_ACTIONS on yield | instructions/meteora/exit_for.rs:37 |
meteora_harvest_for, meteora_compound_for | YieldFeeActions passed by the caller | instructions/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.
- The claim runner selects
COLLECT_FEES_ACTION, which is Harvest.venue/claim.rs:514 - The Orca adapter calls
collect_fees_and_chargewith that class.venue/adapters/orca.rs:355 - The helper requires each token program to own its mint.
venue/adapters/orca_flows.rs:989-990 take_hook_accountsreads 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 theextra-account-metasPDA.venue/adapters/orca_flows.rs:359-402,fees.rs:519-560- The helper records both vault balances.
venue/adapters/orca_flows.rs:999-1002 invoke_orca_fee_collectruns the Orca collect CPI. The caller reloads both vaults and computes each collected delta.venue/adapters/orca_flows.rs:881-915,1003-1009charge_fee_checkedcomputesfee_amount(Harvest, collected_a). A zero fee returns 0 with no CPI.fees.rs:627-630,state.rs:926-933- It reads the mint's decimals from raw bytes.
fees.rs:632-635 - It builds a
TransferCheckedinstruction with the vault, mint, collector, and Sickle PDA, appends the hook account metas, and invokes it with the Sickle seeds.fees.rs:650-671 - It returns the fee. The caller emits
FeeChargedwith 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
- No fee class exceeds 500 bps.
set_feereturnsFeeTooHigh.instructions/admin/set_fee.rs:21 - The fee is the floor of
amount * bps / 10_000, computed inu128. The only error isMathOverflow, which the cap makes unreachable.state.rs:926-933 - A zero fee moves nothing.
charge_fee_checkedreturns 0 before any CPI.fees.rs:627-630 - The fee leaves the vault under the Sickle PDA signature only.
charge_fee_checkedsigns with[b"sickle", owner, bump].fees.rs:637 - 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
InvalidAccountOwnerandInvalidFeeCollectorMint.fees.rs:123-137 - A raw balance read accepts the same two owners and the same shape. The
errors are
InvalidAccountOwnerandInvalidProtocolAccountData.fees.rs:173-180 - A mint that enters a position carries only the allowed extensions. A
TransferHookextension returnsUnsupportedTransferHookMint, even when its program id is unset. Any other listed or unknown extension returnsUnsupportedMintExtension. Malformed TLV returnsInvalidProtocolAccountData.fees.rs:360-445 - Forwarded hook accounts start with the mint's hook program and, when more
than 1 is declared, the
extra-account-metasPDA of that program and mint. The error isTransferHookAccountsMissing.fees.rs:519-560 - A token program passed beside a mint is executable and owns the mint. The
error is
InvalidAccountOwner.fees.rs:695-710
Tests
| Location | Covers |
|---|---|
fees.rs:773-834 | The four byte positions, against bytes the upstream Pack implementations produced |
fees.rs:848-941 | Both raw token-account readers: the shared shape, and the exact code each gives a malformed one |
fees.rs:950-1007 | A Token-2022 mint serialized by the upstream extension writer: the TLV constants, the hook scan, the custody refusal |
fees.rs:1027-1193 | Collector 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-1130 | token_account_amount_from_bytes reads both programs and rejects five malformed shapes |
fees.rs:1203-1215, fees.rs:1612-1622 | Mint decimals for both programs, and three malformed mints |
fees.rs:1218-1296, fees.rs:1299-1363 | token_program_for_mint on both programs and a foreign owner, and assert_token_program_for_mint |
fees.rs:1392-1404 | TransferChecked data bytes for two amounts |
fees.rs:1407-1609 | Hook program read: classic, bare, unset, set, behind other extensions, truncated, overrun, account-as-mint |
fees.rs:1448-1470, fees.rs:1511-1587 | Mint-extension policy: dormant hook rejected, 12 allowed extensions, 14 rejected, unknown, malformed |
state.rs:1219-1248 | fee_amount floors to the unit, is zero when unset, indexes Deposit and Withdraw, no overflow |
state.rs:3097-3181 | Property tests: fee never exceeds the amount, is the exact floor, is monotonic, never overflows |
venue/policy.rs:90-158 | Every row of the matrix |
fees.rs:1693-1722 | hook_account_metas copies both privilege bits |
fees.rs:1732-1864 | take_hook_accounts: the head pin, the validation PDA, the declared length, the cursor |
fees.rs:1878-1954 | The four shapes the two TLV walks answer differently |
venue/claim.rs:816-858 | The four constants and the yield-fee grouping |
Gaps
| Gap | Evidence |
|---|---|
charge_fee_checked has no Rust test | fees.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
L09-R001 —
charge_feehas no caller inprograms/sickle/src. Its doc comment names a pending Orca*_v2migration as the reason it remains, and the Orca legs callcharge_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.rsunderprograms/including untracked files.git grep -w charge_feereturned the definition, two doc cross-references, and prose. Zero call sites. No workspace crate depends onsickle: the members areprograms/*, andmock-transfer-hookdepends only onanchor-lang. It waspubrather thanpub(crate), which is why nothing warned. Deleting it cannot change behaviour, because nothing called it -- the argument is the grep, not the artifact. The.sodoes differ, for the threerequire!origins inassert_token_program_for_mintthat 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, andtests/mainnet-acceptance.ts-- and all five now namecharge_fee_checked.TODO.mdkeeps its references: it is a dated plan log and those lines record what was true when they were written.L09-R002 — The doc comment describing
take_hook_accountsruns into the doc comment describinghook_account_metas, and both attach tohook_account_metas.take_hook_accountshas 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 abovetake_hook_accounts, andhook_account_metaskeeps only its own. The same bytes, reordered: no wording changed, and the two functions are unmoved, so every line below them keeps its number.L09-R003 —
validate_collector_bytesandtoken_account_amount_from_bytesrepeat the same owner, length, state-byte, and type-byte checks with different error codes.InvalidFeeCollectorMintin one,InvalidProtocolAccountDatain the other. Resolved in #234 as a drift defect, because nothing held the two copies together: the tests asserted onlyis_err()on each, so a byte-layout fix to one would leave the other's tests green. Both now callrequire_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 logsProgramError 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 == 0where it was!(len >= BASE && state != 0)-- which is the same predicate by De Morgan and short-circuits identically, sodata[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. Andrequire!cannot carry the error as a variable: its single-token-tree arm captures a bare identifier ascrate::ErrorCode::<ident>, so the three shared checks spell their branches out, which is what lets the code be a parameter at all.L09-R004 —
charge_fee_checkedreturns 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 emitFeeCharged.amountfrom 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 onFeeChargedwhere 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 ahook_accountsparameter 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.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 inmeteora_claim_feesand the one inmeteora_claim_reward; it did not name the four EVENTactionfields in those same handlers, which report the class that was charged and were equally literal. Nor did it namemeteora_rebalance_forandmeteora_exit_for, whose charge sites already read the table throughREBALANCE_PRINCIPALandEXIT_PRINCIPALand whose event fields, nine lines away in the same loop, still saidFeeAction::RebalanceandFeeAction::Exitbeside a derivedHARVEST_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 fromposition_fee_policyfails 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. DroppingRebalance.principalorExit.principalreports inside the handler itself (instructions/meteora/rebalance_for.rs:38,instructions/meteora/exit_for.rs:35) because those handlers define their ownconst. DroppingCollectFees.pool_feesorCollectRewards.rewardsreports atvenue/claim.rs:159and:161, where the shared const is defined, because the claim handlers consumeCOLLECT_FEES_ACTIONandCOLLECT_REWARDS_ACTIONrather 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 ininstructions/meteora/, zeroFeeAction::occurrences outside#[cfg(test)]and outside comments.claim_fees.rsandclaim_reward.rshold none at all;rebalance_for.rsandexit_for.rshold 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 oneconst, 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.L09-R006 —
charge_fee_checkedbuilds theTransferCheckedinstruction by hand whilefinalize_intentuses 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 readsCpiContext::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_setrequires only that it be reachable over the signed swap edges and does not require it to be a pool mint,snapshot_extra_baselineschecks only its vault's mint identity and owner, and a target that is a swap output never passes through an increase, soassert_mint_extensions_for_custodynever 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.L09-R007 —
transfer_hook_extension_value_offsetandvalidate_mint_extensions_for_custodyboth walk the same TLV. The first walk stops when fewer than 4 bytes remain. The second walk returnsOkon 1 trailing byte andInvalidProtocolAccountDataon 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 anUninitializedentry 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 bythe_two_tlv_walks_disagree_on_these_exact_shapesand the reasoning sits ontransfer_hook_extension_value_offset, at the point of temptation. The safety argument for the load-bearing case: stopping atUninitializedis what the token program's ownget_tlv_indicesdoes, 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-closedTransferHookAccountsMissing, never a missed live one. Verified by making each walk adopt the other's rule and watching the test fail.L09-R008 —
token_program_for_mintreturns 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_minton the Orca reward path, andmint_decimals_from_bytesinsidecharge_fee_checkedon the Raydium, Jupiter and Meteora paths. Both reject a foreign owner withInvalidAccountOwner. 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 intoken_program_for_mint_selects_by_owner, and the containment is stated on the function rather than left to be rediscovered.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_HOOKandTRANSFER_HOOK_EXTENSION_LEN. The four that are byte POSITIONS no upstream constant names are asserted against the bytes a realspl_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 -- pointingEXTENSION_TYPE_TRANSFER_HOOKat 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.tsreads this file as TEXT and regex-matchesconst <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 becauseanchor_spl::token::TokenAccount::LENcould not be used in aconstcontext. 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.L09-R010 —
EXTENSION_TYPE_TRANSFER_HOOKis the literal 14, whilevalidate_mint_extensions_for_custodyconverts the same number throughExtensionType::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 aconstassertion againstExtensionType::TransferHook as u16now sits beside it. The two walks can no longer disagree about which number it is: if they did, the build fails.L09-R011 —
fees.rsmixes 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_checkedtakes 11 parameters atfees.rs:597-628, and it has exactly 19 call sites -- 3 invenue/adapters/raydium_flows.rs:226-330, 4 invenue/adapters/orca_flows.rs:659-670,994-1018,1215-1226, 10 acrossinstructions/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, andinstructions/meteora/exit_for.rs:357-381, and 2 ininstructions/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_checkednow takes five parameters atfees.rs:620-626, and all 19 call sites pass borrowed&SickleSignerand&TransferLegvalues. The fields inside both bundles are borrowed too. An initial version owned clonedAccountInfohandles; that reduced the call boundary but enlarged all four Meteoraclaim_all_yieldframes 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.L09-R012 —
jupiter_swapselects Deposit and Withdraw atinstructions/jupiter/swap.rs:125-144. Those two classes appear nowhere invenue/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.PositionOperationandposition_fee_policyexplicitly describe position operations atvenue/policy.rs:10-32; a Jupiter swap leg is a different axis. Its caller-declared context is fail-closed instead: the match atinstructions/jupiter/swap.rs:125-144accepts only Deposit or Withdraw and returnsInvalidSwapFeeActionfor every other class. The position matrix remains exhaustive for the domain it names.L09-R013 —
assert_fee_collector_accountis called by the Raydium adapter atvenue/adapters/raydium.rs:501-510,669-678,942-951and byinstructions/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 Anchortoken::mintandtoken::authority = global_config.fee_collectorconstraints;instructions/orca/collect_fees.rs:121-135is 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 callassert_fee_collector_accountbefore 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.
Related
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".