Sickle Sol

Simplification and audit notes: summary

This page groups the 104 open notes at the end of the ten layer pages into themes, so the refactor and audit-preparation work can be planned as a whole.

Every note on every layer page is a hypothesis, not an instruction. These were written by reading the code and describing what looked repeated or unused. That generates candidates well and instructions badly. Establish that the claim holds before acting on it, and when it does not, write down what was actually true. Four notes worked so far were wrong in a way that would have caused a regression if executed literally, including one whose literal execution would have removed an owner's ability to exit a paused program.

Ask what stops the copies diverging. A note that describes a redundancy is either a style point or a drift defect, and the note itself usually cannot tell you which. The question separates them. If nothing makes the copies keep agreeing, it is a drift defect, and the fix is to make one place derive from the other rather than to make them look alike. If a test or the compiler already holds them together, it is style and it can wait.

Three notes worked in the layer 03 pass read as tidiness and were drift. The clearest is historical note 36: two Meteora handlers wrote a fee class as a literal beside a table that calls itself the single exhaustive fee matrix. The values agreed, so nothing was wrong, and nothing made them keep agreeing. Reading the class from the table now makes an edit that drops it fail the build inside the handler; the literal would have survived that edit silently.

Where these notes go wrong most often. Two of the first nine worked in the layer 03 pass describe a shared condition wrapped in different control flow. Comparing the condition confirms the note. Reading the lines around it refutes the note. One counted three copies of a pause check when the third used assert_not_paused_unless_owner_exit, so merging them would have removed an owner's ability to exit a paused program. Another counted three definitions of one helper when the third was a const fn that panics, because a const context cannot return a Result. Check the surrounding lines, not only the lines the note cites.

Each layer page keeps the full list with path:line citations. This page links to them and does not repeat them. Read the theme, then open the layer page for the lines. No note here proposes a fix. The date of the source read is 2026-09-08. A layer page lists the notes a merged PR resolved under a "Resolved" heading; the table counts only the open notes. Each open note has a stable ID such as L03-N020. Resolved records that predate the convention have bootstrap R IDs. Ordered-list positions are presentation only and must not be used as identities; older positional references are historical.

Notes per layer

Themes

1. The same code exists in two or more places

This is the largest theme. It appears in every layer.

The pre-extraction baseline was measured on 2026-09-09 by scripts/dev/measure-struct-overlap.sh, which extracts each named account struct, normalises the venue name out of both, diffs them, and reports added plus deleted lines over the sum of both block lengths. The figures below are preserved at commit 2b13bdf; run the script from a checkout of that commit to reproduce them. The yield rows are now historical baselines for the Orca and Raydium extractions described below.

  • Owner and keeper structs within one venue shared the most at the baseline. The Orca and Raydium harvest/compound and collect/harvest rows and both owner/keeper rebalance rows have now been extracted. The handler bodies behind harvest and compound were already shared through one runner call, so the landed changes target the account declarations rather than introducing another flow abstraction.

    PairOrcaRaydium
    harvest vs compound85% shared87% shared
    collect vs harvest80% shared83% shared
    keeper vs owner rebalance72% shared72% shared
  • The same flow across the two venues shares less, but not little. The summary previously said these structs are "copied between the two venues with a few type changes", which the measurement does not support: no cross-venue pair exceeds 65 percent shared, against 72 to 87 within a venue. It is a weaker candidate, not an empty one.

    FlowShared
    compound_for, exit_for65%
    harvest_for54%
    rebalance_for53%
    owner flows (open, increase, decrease, collect, close, rebalance)41-60%

    The handler bodies diverge much further than the structs: whole-file comparison gives 774 differing lines for the decrease pair and 1,239 for the rebalance pair, because Raydium's collect-then-drain fee split and its bitmap handling are real structural differences rather than copy-paste.

    The landed Orca and Raydium extractions use nested Anchor account structs that flatten to the exact same on-chain field order. The answer is yes for account order and no for IDL shape. The four conditions they use are below; they are a decision procedure, so the remaining pairs can be planned without repeating the experiment.

  • Nesting preserves the on-chain account order exactly, and changes the IDL. Measured and then implemented on Raydium and Orca collect, harvest, compound, owner rebalance, and keeper rebalance at anchor-lang 1.1.2. Raydium's five yield groups hold 16 shared accounts in instructions/raydium/yield_accounts.rs:16-95; Orca's five hold ten accounts common to all three instructions plus two reward-program accounts common to the keeper pair (instructions/orca/yield_accounts.rs:17-77). The rebalance pairs add seven Raydium groups with 20 accounts (instructions/raydium/rebalance_accounts.rs:15-111) and six Orca groups with 16 (instructions/orca/rebalance_accounts.rs:12-108).

    A composite field emits account_metas.extend(self.<field>.to_account_metas(None)), so it flattens in place, in declaration order. Every pubkey position, is_signer and is_writable survived: the tables in instructions/raydium/account_order_tests.rs and instructions/orca/account_order_tests.rs were frozen from the flat structs, committed passing, and were unchanged by the nesting. cargo build-sbf --tools-version v1.52 printed no frame diagnostic.

    A freshly generated IDL differs, and it is worth being exact about what moved, because the IDL's account ORDER, its account NAMES, its per-account ATTRIBUTES, its NESTING and its DOCS are five things. The first three stayed fixed; nesting and the now-centralized field descriptions changed.

    Flattening each instruction's IDL account list through its composites gives a list identical to the one before, for all ten affected instructions: same accounts, same order, same name, signer, writable and optional. The previously copied descriptions became one description per shared field. No instruction outside the ten moved even structurally. What changed is that the shared top-level entries became IdlInstructionAccountItem::Composite entries holding the same accounts. So the break is purely structural: a client that flattens composites sees nothing at all, and accountsStrict -- which the builders use -- matches the IDL shape and therefore needs nested objects at every call site for an affected instruction. The root builders and direct test callers were migrated with the IDL.

    The on-chain format is preserved and the client's spelling of it is not. That is the condition the original sentence assumed away by treating the IDL and the account order as one question.

    SCOPE OF THE ORDER CLAIM, stated because these are easy to conflate. What was executed is the client-side ordered meta list (ToAccountMetas on the generated client struct, which is the list a caller puts on the wire) and the IDL's flattened account list -- two independent renderings, both unchanged. The source-level pins do not execute try_accounts, the program's consumption side, or the generated CPI-client module. Both are emitted from the same field list by the same macro, and that codegen was read, but the Raydium and Orca Surfpool lifecycles remain the runtime gates for the parser side.

    That unexecuted half is the asymmetric one: being wrong on the client side costs a builder migration, and being wrong on try_accounts costs a broken instruction on chain. The extraction is therefore not mergeable unless the Surfpool Raydium lifecycle passes on its exact head.

  • A group is extractable only if all four hold. Each was tested, not reasoned about.

    1. Contiguous, because a composite flattens in place.

    2. Meta-identical in every struct that takes it -- same names, order, is_signer and is_writable.

    3. Closed under the constraints written inside it. A composite cannot see the outer struct, so every field named by a constraint inside the group must be inside the group. Both other directions work: an outer constraint may reach in as group.field, and a raw constraint on the PARENT may relate an inner account to an outer one -- #[account(constraint = group.item.key() == authority.key())] compiles and is enforced. So a relation can sometimes be re-expressed one level up. What cannot move up is a seeds/bump derivation, by rule 4; rewriting one as a raw equality changes the error from ConstraintSeeds to ConstraintRaw, which is a behaviour change rather than an extraction.

    4. No built-in #[account(...)] attribute on the group. A composite field accepts a RAW constraint (constraint = ...) and nothing else: mut makes the derive macro panic, and so does seeds/bump. A raw constraint does not alter the generated writable/signer metas, so the one kind of attribute a composite does accept is the kind that cannot absorb a meta difference.

      This one is anchor's own invariant rather than a property of one experiment, which is the difference between a rule that changes silently on an upgrade and one that changes loudly: anchor-syn-1.1.2/src/codegen/accounts/constraints.rs:64-76. Two things there agree -- the generate_composite match arm, which accepts Constraint::Raw(_) and panics on everything else, and the #[allow(...)] reason comment above it, linearize() only yields Raw for CompositeField.

      The panic message on line 74 is not the rule, and is quoted here so the next reader does not adopt it. It says composite constraints "can only be raw or literals", but this version has no Constraint::Literal: the enum is at anchor-syn-1.1.2/src/lib.rs:817-833, and #[account("1 == 1")] does not even parse. The message is a leftover from an older anchor. Both sessions on this work found it before finding the code, and taking it at face value would widen the rule this condition exists to state.

    A composite also has to be imported with a glob (use module::*), because the derive emits unqualified references to its sibling generated modules.

  • One flag on one account cost this pair its whole prefix. Harvest's 24 declared accounts are exactly the first 24 of compound's 29, in order -- and 23 of them are meta-identical. authority is not: it is #[account(mut)] on compound, which fronts the transient ActionIntent rent, and plain on harvest. By rule 4 that cannot be absorbed, and the reason is narrower than the rule first looks: is_writable comes from mut, a composite field may not carry mut, and the raw constraint it may carry does not change metas. Independently, mut on a composite would mark the whole group writable rather than one account, so it could not express "writable in B, not in A" even if it were permitted. So authority stays out; by rule 3 global_automator follows it out, since its seeds are [KEEPER_SEED, authority.key()] and a seeds derivation is exactly what cannot be lifted to the parent.

    What survives is five closed blocks totalling 16 of the 24 accounts: [global_config, sickle], [clmm_program .. personal_position], [tick_array_lower, tick_array_upper], [token_vault_0 .. memo_program], and [token_program .. tick_array_bitmap_extension]. Without the authority difference the whole prefix would be a single composite.

    Orca stops for the same authority/global-keeper reason and at one additional constraint boundary. Its four all-flow groups hold config, position, mints, and token programs; a fifth holds the keeper pair's reward token programs. The destination vaults and fee collectors stay inline because their built-in token constraints name fields outside any contiguous closed group. Hoisting or replacing those attributes would change Anchor's validation or error precedence, so the smaller extraction is the behavior-preserving one.

  • The extraction unit was not the pair. The shared implementation macros index the accounts struct by field name, so nesting a group forces every struct that shares those macros to nest the same group. claim_raydium_yield! has three call sites -- collect, harvest and compound -- so extracting the pair pulled RaydiumCollectFees in with it. The five groups were adopted by all three structs in one change, and the account-order pins cover all three.

    Orca follows the same extraction unit: its claim traits serve owner collect, keeper harvest, and keeper compound, so all three adopted the four common groups together, with the reward-program group on the keeper pair.

    The structs also carry two more accounts than their source shows. #[event_cpi] appends event_authority and program to both, after every declared field, so harvest and compound are 26 and 31 accounts.

  • The implementation bodies behind those structs are now shared. The adapters write the flow policy, the keeper intent methods, the drain bundle, the reopen receipt, the drain-outcome mapping, and the Orca CPI account order once each as a macro, invoked per struct. See layer 04. The bindings share one tick-range check, one reward-slot scanner, and one Memo program constant, hosted in orca.rs because that layer has no module of its own for shared code. See layer 05. Both venues' yield trios now share their constraint-closed account groups.

  • Owner and keeper pairs. The rebalance and exit runners share the same rent, event-mapping, and intent-begin sequences with small differences. The three compound trait implementations differ only by field names. See layer 03.

  • CLMM and DLMM authorization. The preauthorize, cancel, expire, and finalize handlers exist twice with one field of difference. See layer 08.

  • Builder and management UI. The UI has its own implementation of the PDAs, tick and liquidity math, fee math, swap planner, and authorization codec. One parity test covers a subset. The builder's CLMM owner-rebalance economics and its keeper-intent authorization/swap core are now shared; venue account adapters remain separate, and the UI derives PDAs in five places. See layer 10.

2. A check lives in more than one layer, or in the wrong one

  • The pause check has two forms, and the always-true flag eleven owner-exit handlers pass to it is still an open layer 07 note. The layer 02 half is closed: the two forms are the owner-exit carve-out and merging them removes it, and the third location is gone now that the six keeper claims reach the pause check through their runner.
  • The allowlist check runs three times on one Orca owner rebalance and the two extra copies are not removable: each is the only pre-CPI allowlist check on some other flow. Closed on layer 02 with no change.
  • The owner check had three spellings and now has one, but the real finding was a fourth: two Sickle structs authorize by PDA seeds rather than by a check, which is a different mechanism and not a spelling. Closed on layer 02.
  • Pause and allowlist checks are owned by the runner for some flows and by the handler plus adapter for others, because DecreaseFlowAdapter and CloseFlowAdapter lack the shared policy supertrait. Closing that needs a trait change in venue/. See layers 03 and 04.
  • Shared flow helpers, including the settings transfer used by both venues, live inside Orca handler files and are imported upward by runners and adapters. See layers 02, 03, 04, and the dependency graph.
  • The fee-class table in venue/policy.rs is now the single source for every position operation: all nine Meteora fee-class sites read it at compile time, charge and event alike. Only the Jupiter swap selects a class the table does not carry, which is a different axis rather than a gap -- a swap leg is not a position operation. See layer 09.
  • The keeper decision logic off-chain mirrors the rebalance trigger but not the progress or placement checks. See layer 10.

3. Dead or unused items

  • Two error variants with zero call sites, still present in the UI's IDL copy. See layer 06.
  • Two require_*_finalizer helpers, the pending-venue conformance machinery, and several pub items with a single or test-only caller. See layers 05, 07, 08, 09.
  • Dead builder surface. Resolved 2026-09-15. Six exports had no importer anywhere in the private repository and no package entrypoint exposed them. They are removed together with their orphaned imports. Unused composer parameters keep their positions under the established _connection convention, while unused destructures and aliases are gone. See layer 10.
  • Two state fields written once and never read on-chain. See layer 07.

4. Hand-written code where the framework has a type

  • Account space derivation. Resolved 2026-09-16. The four layer-07 account structs and their fixed-size nested settings types derive InitSpace; the retained SIZE constants delegate to it. One serialization test pins both the deployed numeric sizes and every derived value. GlobalConfig keeps its explicit legacy-prefix size because the migration addresses that historical byte boundary directly. See layer 07.
  • The authorization writer and loader re-implement Anchor's account serialization and checks. See layer 08.
  • One fee path builds the token transfer by hand while another uses the anchor-spl wrapper. See layer 09.
  • Keeper adapters re-implement constraints that the owner structs express as Anchor attributes. Some accounts are UncheckedAccount where a typed interface account is used elsewhere for the same role. See layers 02, 04. Measured 2026-09-11 for the layer 02 half, and decided: no change. Typing them costs an SBF frame in exactly one place, RaydiumRebalanceFor, at 15 diagnostics; everywhere else it measures zero. It was still declined, because the constraint duplicates a runtime check it cannot replace and the compute margin on those flows has never been measured. Each field now carries its own measurement rather than a reason inherited from a different struct.
  • The event emitter copies Anchor's macro expansion and will not follow Anchor changes. See layer 06.

5. Long functions

This is a navigation aid, not an audit-readiness metric. The original length cutoff found several real seams, but merely crossing it does not close a risk and exceeding it does not establish one. The on-chain entries are gone because their contracts removed measured duplication or isolated an SBF-frame boundary with focused tests, not because their final line counts fell below the cutoff. Do not open another contract from this table without one of those concrete reasons.

The remaining entries are TypeScript orchestration surfaces in layer 10, not the on-chain audit target:

FunctionLinesLayer
MeteoraKeeperRuntimeStore (TypeScript)112810
MeteoraKeeperRuntime (TypeScript)98810
/api/tx POST handler (TypeScript)62210

6. Comments that no longer match the code

  • CHECK comments in 22 handler files said a check happens "in the handler" when it happens in the runner or adapter. Closed on layer 02: a comment now names the check rather than the layer, so the next code motion does not falsify it.
  • Comments cite a "deferred" reward emission that ships, a "pending" Orca migration that landed, and an identifier defined nowhere. See layers 01, 06, 08, 09. The "Phase 3" Jupiter was historical layer 02 note 6 and is closed.
  • Approximate byte sizes in comments differ from the constants. See layer 08.
  • Comments cite EVM contracts that are not in this repository. See layers 01 and 07. The security-review item numbers are closed: contract 13 read the 19 in source and the 6 in CLAUDE.md one at a time and removed 23, because each sat beside a sentence that already gave the reason. Two survive: intent.rs:1, anchored to TODO.md, and the MEDIUM #4 note in CLAUDE.md, left untouched and rewritten by a later pass. The premise recorded here and in layer 02 -- that those references were unopenable -- was false: TODO.md enumerates every finding and README.md defines seven of the eight again.
  • Doc comments attached to the wrong item in two places. See layers 08, 09.

7. Behavior worth a second look before the audit

These notes describe behavior, not style. Each needs a decision from the team before a refactor touches it, because a refactor can otherwise bake in an answer silently. A decided item carries its decision and the date; an undecided one still blocks the contract that would change it.

  • Raydium reward events. Three Raydium handlers emit the fee event per reward but no RewardsCollected event, unlike their Orca counterparts. An indexer sees Raydium reward fees but not reward amounts. Layer 06. Decided 2026-09-10: change it. Landed 2026-09-10. Verifying the note found it understated: FeeCharged is emitted only for a non-zero fee, so a reward whose fee floored to zero produced no event at all and its amount was unobservable, not merely inconvenient to derive. Additive for consumers, since the event already exists in the IDL and no field changed. The six sites across both venues now derive both halves from reward_event_pair, so a venue cannot emit one without the other. The first attempt then introduced a second falsehood, caught in review. A Raydium reward whose mint equals a pool token is paid inside that pool token's delta, so the receipt reports collected == 0 for a slot that did pay. Emitting unconditionally published that zero as a measured amount. RewardClaimDelta now carries the disposition the Raydium adapter had been dropping, and reward_event_pair withholds the pair entirely when the payout is not separable, which is what owner_rebalance::map_drain_events already did on the rebalance and close paths.

  • Session expiry. Decided by Codex 2026-09-16: reject an already-expired active session at write time while keeping revocation expiry-independent. set_intent_session now applies the same strict future-slot rule as IntentSession::check_covers before it stores an active session. Inactive updates retain an expiry-independent path so an owner can still revoke a session after its deadline. Layer 08. Landed 2026-09-16.

  • Write-time versus use-time settings checks. A zero low cutoff passes the write-time check and fails at the keeper. Tick-space widths have no write-time cap. A stale nested reward mint survives when auto-rebalance is off. Layer 07. Decided 2026-09-10: add an explicit enabled flag. Not yet landed. All three sub-claims hold, and they share one root: tick 0 is both the "unset" sentinel and a legal tick meaning price 1.0, so the sentinel cannot be told from a real value. validate_with_geometry enforces the reward config only inside the auto_rebalance branch, and caps no tick-space width at all. Separating the flag from the value makes tick 0 usable and lets the write refuse exactly what the keeper would refuse. It changes the PositionSettings layout, so it needs its own contract with a migration for existing accounts; do not fold it into an unrelated change. Landed 2026-09-10 without the flag, and without a layout change. Building it surfaced two things the decision was taken without. First, the migration is far more expensive than the GlobalConfig precedent suggests: GlobalConfig is a singleton with a one-shot admin migration, while PositionSettings is one account per position, each owned by a different owner, so every existing account would need migrating before its automation worked again. Second, the flag was not required for the outcome. All three sub-claims came from using a value as a proxy for whether it had been configured, and asking the question directly fixes them: the band now tests cutoff_tick_low < cutoff_tick_high, which makes tick 0 usable, still rejects the unpopulated (0, 0), and additionally rejects an inverted band the old test allowed. The width pair is capped against the family's index span and the nested reward config must be cleared while auto-rebalance is off. An explicit flag remains available later if the extra expressiveness is wanted for its own sake; it is no longer needed to close this item.

  • Owner compound tail. The owner compound path returns before reading the remaining-account tail, so extra accounts pass unchecked, unlike the owner rebalance and exit paths. Layer 03. Decided 2026-09-09: change it. Landed 2026-09-09. Owner compound validates its tail the way owner rebalance and exit do, through the shared validate_owner_intent_tail. Compatibility: every owner-signed caller that supplies an unassigned tail is affected, not only the reference builder. The instruction is public, so a caller outside this repository can construct it too. The constructors in this repository are scripts/builder and the legacy management UI's composeMeteoraOwnerCompoundAction, and both must be checked when the change lands. The keeper path already validates its tail.

  • Fee event action field. FeesCollected.action carries the principal action while the amounts were charged as pool fees. Layer 03. Decided 2026-09-09: change it. Landed 2026-09-09. The field reports the fee action actually charged on those amounts. Consumers that read the current value see a changed meaning, so the change is called out in RELEASE_RUNBOOK.md under Consumer-visible changes.

  • Gross versus net fee amount. The fee event reports the gross debit; a Token-2022 collector receives net of the transfer fee. Layer 09. Decided 2026-09-10: keep the gross debit. Landed 2026-09-10. Gross is what the fee policy determines, through GlobalConfig::fee_amount, while the net receipt depends on a mint parameter the program does not choose. It is also the figure an owner reconciles a cost against. The rule now sits on FeeCharged itself, where a consumer reads it: reconcile an owner's cost against this number, and apply the mint's schedule before reconciling a collector's balance.

  • Devnet program ID. The devnet entry in Anchor.toml differs from declare_id!, and no test asserts the pair. Still open. A 2026-09-10 reading that called it handled was wrong and was withdrawn the same day. That reading saw anchor keys sync in scripts/deploy/deploy.sh and concluded the script reconciles the ID before building. It does not, and the script itself shows why: the sync is invoked bare, while anchor deploy further down is passed both --provider.cluster "$URL" and --program-keypair "$PROGRAM_KEYPAIR". The sync therefore sees neither this run's cluster nor its keypair, and Anchor.toml configures the provider cluster as localnet. A devnet run can leave the programs.devnet entry untouched, and a custom program keypair can leave declare_id! built from a different target/deploy keypair than the one deployed. The clean-tree gate does not catch either, because a sync that changes nothing leaves nothing to catch. Layer 01. Decided 2026-09-10: declare both ids in source, and drop the sync. Landed 2026-09-10. Neither undecided question survived contact with the tool. Passing the cluster and keypair through is impossible: anchor keys sync accepts only --program-name. And "what pair a test should pin" dissolves once both ids are constants, because each arm pins to its own cluster. Both now sit in lib.rs behind the devnet cargo feature as reviewed constants, programs/sickle/tests/program_id.rs pins each against its Anchor.toml entry, and the sync is gone from deploy.sh and upgrade.sh. That removes the deeper problem too: it rewrote tracked source mid-deploy, which is what the provenance gate exists to forbid.

  • Cancel versus expire constraints. Expire structs carry a sickle constraint that cancel structs do not. Layer 08. Closed 2026-09-10: no behavior change. The constraint cannot fire in either place. The seeds derive the account from sickle, and preauthorize_intent requires the same equality before storing, so the two always agree. Mirroring it onto cancel was the tempting move and the wrong one, because a check that can never fail reads as protection while giving none. Both struct pairs now carry a comment saying so.

  • Server-held keypair. One Meteora coordination path generates a keypair on the server, against the stated rule that keys never reach the server. Layer 10. Resolved 2026-09-14. The full trace showed two independent secrets: the UI cache never used its key to sign, while the keeper generated another solely for Meteora account creation. The replacement position is now a Sickle PDA derived from the consumed authorization nonce. Both on-chain rebalance legs enforce that address, and the program supplies the CPI signer with invoke_signed. The keeper retains only its ordinary service identity; the UI retains no private key, and a restart can recover the destination from durable public state.

  • Third-party upgrade authority. Every external program Sickle allowlists is upgradeable and none is immutable, but in every case the authority is a program-derived address governed by a Squads multisig, not a key. Measured 2026-09-10 against mainnet with scripts/dev/check-program-authorities.py --upgrader. Layer 04, layer 05.

    VenueAuthorityGoverned by
    Orca WhirlpoolGwH3Hiv5…Rx4PVSquads v4
    Raydium CLMMFytDrVzD…ceZQKSquads v4
    Meteora DLMMJADaUV8k…uCVLdSquads v3
    Jupiter v6CvQZZ23q…jtipQSquads v3

    Each authority is off the ed25519 curve, so it has no private key and signs only when a program calls invoke_signed. Which program is not inferred from the account: for each venue the transaction at its ProgramData's deployed slot was located, required to have succeeded, and required to contain a loader upgrade naming that exact program, signed by that exact authority. The Squads id above is the top-level instruction that issued that loader call, resolved through the inner group's parent index rather than by collecting the transaction's programs -- a transaction can carry unrelated top-level instructions, and an earlier version reported those as upgrade drivers too.

    Not established, and not to be read into this: the threshold, the signer set, or who those signers are. "A Squads multisig governs it" is a statement about the shape of the control, not its strength. A 1-of-1 Squads is a single key with extra steps.

    Two earlier readings of this measurement were wrong, in opposite directions. The first called all four "a plain keypair, one signature", having read System ownership and zero data bytes as evidence of a key; that is exactly the account shape of a PDA used as a signer. The second named a governing program per venue from the latest transaction merely mentioning ProgramData, without checking that it succeeded, that it upgraded anything, or that it was even the deploy -- and that attribution was wrong for three of the four, by millions of slots. Both are recorded because the note is evidence for an audit, and an audit should see that the number moved and why.

    This is not a defect and nothing in this repository can fix it. It is the trust boundary the design already assumes: fees.rs says the token-program pinning "does not replace the trust boundary around the admin-allowlisted Orca program itself", and the same holds for the other three. Whoever can drive one of these multisigs can replace that program's code under every position it custodies. Sickle's mitigations are the admin allowlist and the ability to revoke a slot, both of which act after a decision to stop trusting a venue, not before one.

    Undecided, two things. Whether to read each multisig's threshold and members, which is what would turn "a multisig governs it" into a risk anyone could weigh. And whether to monitor these authorities and their deploy slots, which would turn an unannounced upgrade of an allowlisted venue from something noticed later into something noticed at the time.

  • Builder-keeper dependency direction. Resolved 2026-09-14. Orca account layout decoding now lives entirely in the builder. The keeper consumes its narrow pool and position views from there, while the shared foreign-account error sits in a venue-neutral builder module. A source-level test enumerates every builder import and rejects any edge back into scripts/keeper/.

8. Test gaps

  • The handler-file census is structural, not behavioral coverage: 35 of 65 handler files have no Rust unit-test module, while many are already exercised by validator tests. Layer 02 now requires a named mutation that survives every existing test, or a seam needed by a measured refactor, before another host handler test counts as closing the gap. The Orca adapter has four tests and the Raydium adapter still has none. Layers 02, 04.
  • 30 error variants are named by no test on either side. Layer 06.
  • No fake-adapter test for the six check-heavy runners. Layer 03.
  • No fork suite passes a Raydium TickArrayBitmapExtension or calls the standalone orca_collect_rewards. Layer 05.
  • No test enforces the double-emission convention or the instruction count. Layers 01, 06.

How to use this page

  1. Pick a theme, not a layer. Duplication removal in theme 1 touches several layers at once and is safest done per pair.
  2. Resolve theme 7 first. Each item needs a yes or no from the team before the refactor starts, because a refactor can hide a behavior change.
  3. Treat theme 8 as the entry ticket for each refactor. Write the missing test, then change the code.
  4. Update the layer page and strike the note when a change lands.

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