Sickle Sol

State and settings

This layer defines every program account, the authorization methods on the Sickle, the position settings rules, and the per-venue index limits those rules use.

Files

PathLinesPurpose
programs/sickle/src/state.rs3253The four program accounts, the venue enum, the fee enum, the settings structs, and their validation.
programs/sickle/src/settings.rs510Reads and closes the position settings account that a close or rebalance instruction receives unchecked.
programs/sickle/src/venue/family.rs314Classifies each venue as CLMM, DLMM, or non-position and resolves its index limits.
programs/sickle/src/intent.rsn/aNot in this layer. Holds the authorization accounts. See section "Public items", last table.

The production code is lines 1 to 1153 of state.rs. Its two unit-test modules are lines 1155 to 3022 and 3023 to 3253.

Responsibilities

  • Define the byte layout of the global config, the Sickle, the position settings, and the global keeper record.
  • Decide who is the owner, who is the manager, and who is an authorized keeper for one Sickle.
  • Decide in what capacity a caller may add liquidity on a keeper path.
  • Store the protocol fee table and compute one fee amount.
  • Store the allowlist and check a CPI target against it.
  • Store the pause flag and check it.
  • Validate position settings when the owner writes them and when a rebalance copies them.
  • Decide whether a keeper harvest, compound, rebalance, or exit is allowed for one position.
  • Compute the exact tick range a keeper rebalance must reopen.
  • Refuse a keeper rebalance that makes no progress.
  • Read, check, and close a position settings account that arrives as an unchecked account.
  • Resolve the tick or bin limits for one venue.

Public items

state.rs

ItemKindMeaning
FeeActionenumThe six fee types. The discriminant indexes GlobalConfig.fee_bps.
ProtocolenumThe venue. The discriminant is serialized state and instruction data.
RewardBehaviorenumWhat automation does with rewards: None, Harvest, or Compound.
RewardConfigstructThe reward behavior and an optional harvest output mint.
RewardConfig::SIZEconstSerialized size of RewardConfig.
RewardConfig::validatefnRejects an output mint unless the behavior is Harvest.
RebalanceConfigstructWidth, buffers, caps, cutoffs, delay, and a nested reward config for auto-rebalance.
RebalanceConfig::SIZEconstSerialized size of RebalanceConfig.
RebalanceConfig::validate_tick_widthfnChecks that a tick range has the configured width in tick spaces.
RebalanceConfig::expected_rebalance_rangefnComputes the one tick range a keeper rebalance must reopen.
RebalanceConfig::validate_bin_widthfnChecks that an inclusive bin range has the configured width.
RebalanceConfig::expected_rebalance_bin_rangefnComputes the one bin range a keeper rebalance must reopen.
ExitConfigstructThe exit trigger band, per-side output mints, and caps for auto-exit.
ExitConfig::SIZEconstSerialized size of ExitConfig.
PositionSettingsArgsstructThe caller-supplied part of the position settings, passed to set_position_settings.
From<&PositionSettings> for PositionSettingsArgsimplExhaustively snapshots every caller-owned configuration field.
PositionSettingsstructThe position settings account.
PositionSettings::SIZEconstAccount size with the discriminator.
PositionSettings::apply_argsfnExhaustively replaces every caller-owned configuration field.
PositionSettings::transfer_fromfnCarries identity, configuration, history, timestamp, and bump to a replacement.
PositionSettings::validatefnValidates with the venue's static limits. Fails for Meteora and Jupiter.
PositionSettings::validate_with_geometryfnValidates against a supplied geometry.
PositionSettings::validate_automation_identityfnChecks that the stored Sickle, venue, pool, and position equal the live ones.
PositionSettings::validate_harvest_forfnRequires reward automation in Harvest mode.
PositionSettings::validate_compound_forfnRequires reward automation in Compound mode.
PositionSettings::allows_automator_increasefnWhether a keeper with a permission bitmap may add liquidity.
PositionSettings::validate_rebalance_forfnThe keeper rebalance check for a CLMM tick range.
PositionSettings::validate_dlmm_rebalance_forfnThe keeper rebalance check for an inclusive bin range.
PositionSettings::validate_rebalance_progressfnRejects a CLMM rebalance that reopens the same range or an in-range two-cycle.
PositionSettings::validate_dlmm_rebalance_progressfnThe same rule for an inclusive bin range.
PositionSettings::dlmm_rebalance_historyfnpub(crate). Encodes the closed bin range without colliding with the sentinel.
PositionSettings::validate_exit_forfnThe keeper exit check.
GlobalConfigstructThe global config account.
GlobalConfig::LEGACY_SIZEconstAccount size before the Meteora slot.
GlobalConfig::METEORA_DLMM_PROGRAM_OFFSETconstByte offset of the Meteora slot. Equals LEGACY_SIZE.
GlobalConfig::SIZEconstCurrent account size.
GlobalConfig::is_guardian_or_adminfnWhether a key may pause.
GlobalConfig::assert_not_pausedfnReturns Paused while paused.
GlobalConfig::assert_not_paused_unless_owner_exitfnReturns Paused while paused unless the caller is the owner on an exit path.
GlobalConfig::fee_amountfnThe fee amount for one fee action and one measured amount.
GlobalConfig::protocol_programfnThe allowlisted program for one venue.
GlobalConfig::set_protocol_programfnWrites one allowlist slot.
GlobalConfig::assert_protocol_programfnChecks a CPI target against its slot.
SickleAccountstructThe Sickle account.
SickleAccount::SIZEconstAccount size with the discriminator.
SickleAccount::is_ownerfnWhether a key is the owner.
SickleAccount::is_authorized_automatorfnThe complete keeper authorization decision.
SickleAccount::swap_authorityfnThe capacity in which a key may add liquidity on a keeper path.
SwapAuthorityenumManagement for the owner, Automation(bitmap) for every other authorized key.
GlobalAutomatorstructOne admin-approved global keeper.
GlobalAutomator::SIZEconstAccount size with the discriminator.

settings.rs

All four functions are pub(crate).

ItemKindMeaning
resolve_settings_candidatefnReads the settings at the derived address and returns a SettingsUpdated { removed: true } event.
load_settings_candidatefnReads the settings at the derived address and returns the boxed struct.
close_validated_settings_candidatefnMoves the lamports to the receiver, assigns the account to the System program, and resizes it to 0.
transfer_position_settingsfnCopies a position's settings onto the one a rebalance reopened, re-validates them, and stamps or clears the keeper history.

venue/family.rs

ItemKindMeaning
VenueFamilyenumClmm, Dlmm, or NonPosition.
PositionGeometryenumClmm { min_tick_index, max_tick_index } or Dlmm { min_bin_id, max_bin_id }.
PositionGeometry::familyfnThe family of a geometry.
PositionGeometry::boundsfnThe (min, max) pair of a geometry.
ConformanceenumRegistered or NotApplicable.
MeteoraDlmmVenueenumZero-sized marker type for the Meteora venue.
DlmmVenuetraitSealed marker for a DLMM venue type. Carries PROTOCOL.
RegisteredClmmVenuetraitSealed marker for a CLMM venue type that implements the full CLMM manifest.
RegisteredDlmmVenuetraitSealed marker for a DLMM venue type that implements the full DLMM manifest.
protocol_familyfnGenerated. Maps every Protocol variant to a family. No wildcard arm.
protocol_conformancefnGenerated. Maps every Protocol variant to a conformance state.
Protocol::familyfnCalls protocol_family.
Protocol::position_geometryfnResolves the index limits. Takes Option<(i32, i32)> DLMM limits.

Accounts that live in intent.rs

These accounts are part of the authorization flow and are documented in the Keeper authorization page. This table only records where they are.

AccountDefinitionSeedsSize in bytes
IntentDomainintent.rs:696-704[b"intent_domain"]42
IntentNonceintent.rs:718-727[b"intent_nonce", sickle]49
IntentSessionintent.rs:764-783[b"intent_session", sickle, signer]123
IntentAuthorizationintent.rs:822-849see layer 08see layer 08
DlmmIntentAuthorizationintent.rs:836-845see layer 08see layer 08
ActionIntentintent.rs:898-930see layer 08see layer 08

The sizes come from the SIZE constants at intent.rs:704, intent.rs:726, and intent.rs:783.

Program accounts

All four accounts derive InitSpace; their retained SIZE constants add the 8-byte Anchor discriminator to the derived payload size. The fixed-size nested settings structs derive their SIZE values the same way. The deployed numeric sizes below and their serialized lengths are pinned together at state.rs:1183-1216. GlobalConfig::LEGACY_SIZE remains explicit because the migration addresses that historical prefix directly.

Global config

The global config (GlobalConfig) is defined at state.rs:837-860.

FieldTypeMeaning
adminPubkeyThe admin.
pending_adminPubkeyThe key that may accept the admin role.
guardianPubkeyThe guardian. Pubkey::default() means none.
fee_collectorPubkeyThe fee collector.
fee_bps[u16; 6]The fee in bps per fee action. Indexed by the FeeAction discriminant.
protocol_programs[Pubkey; 3]Allowlist slots for tags 0, 1, and 2. Pubkey::default() means disabled.
pausedboolThe pause flag.
bumpu8The PDA bump.
meteora_dlmm_programPubkeyAllowlist slot for tag 3. Appended after the legacy layout.
PropertyValueSource
Seeds[b"global_config"]constants.rs:4, instructions/admin/initialize_global_config.rs:48
Legacy size246 bytesGlobalConfig::LEGACY_SIZE, state.rs:863-871, asserted state.rs:1454
Size278 bytesGlobalConfig::SIZE, state.rs:873, asserted state.rs:1189
Meteora offset246state.rs:872, asserted state.rs:1455
Created byinitialize_global_config. The admin pays and must be the upgrade authority.instructions/admin/initialize_global_config.rs:39-51
Grown bymigrate_global_config. The admin pays the rent shortfall.instructions/admin/migrate_global_config.rs:54-71
Closed byNothing.no close = on this type

The migration reads the legacy bytes unchecked and checks the discriminator, the length, the admin, and the bump. Then it resizes the account and writes the last 32 bytes (instructions/admin/migrate_global_config.rs:40-71). The test at state.rs:1423-1464 decodes the new layout with a legacy struct and proves that every old field keeps its offset.

Sickle

The Sickle (SickleAccount) is defined at state.rs:970-982.

FieldTypeMeaning
ownerPubkeyThe owner.
managerPubkeyThe manager. Pubkey::default() means none.
automatorPubkeyThe custom keeper. Pubkey::default() means none.
permissionsu8The permission bitmap of the custom keeper.
referral_code[u8; 32]Written at creation. The program does not read it.
versionu16Written as 1 at creation. The program does not read it.
bumpu8The PDA bump.
PropertyValueSource
Seeds[b"sickle", owner]constants.rs:7, instructions/sickle/initialize_sickle.rs:17
Size140 bytesSickleAccount::SIZE, state.rs:985, asserted state.rs:1190
Created byinitialize_sickle. The owner pays.instructions/sickle/initialize_sickle.rs:13-20
Closed byNothing.no close = on this type

Position settings

The position settings (PositionSettings) are defined at state.rs:239-282.

FieldTypeSizeMeaning
sicklePubkey32The Sickle that owns the settings.
protocolProtocol1The venue.
poolPubkey32The pool. Required when auto_rebalance or auto_exit is on.
positionPubkey32The position.
auto_rebalancebool1Auto-rebalance consent.
rebalance_configRebalanceConfig64See the table below.
automate_rewardsbool1Reward automation consent.
reward_configRewardConfig33Behavior and optional harvest output mint.
auto_exitbool1Auto-exit consent.
exit_configExitConfig76See the table below.
last_rebalance_tsi648Unix time of the last rebalance. The program writes it. 0 means never.
prev_tick_loweri324Lower edge of the range the last keeper rebalance closed. The program writes it.
prev_tick_upperi324Upper edge of that range. (0, 0) means no history.
bumpu81The PDA bump.

The discriminator adds 8 bytes. The total is 298 bytes (PositionSettings::SIZE, state.rs:302, asserted at state.rs:1188 and state.rs:1395).

RebalanceConfig is defined at state.rs:98-110.

FieldTypeMeaning
tick_spaces_belowu32Width in tick spaces below the current tick when the keeper reopens.
tick_spaces_aboveu32Width in tick spaces above the current tick when the keeper reopens.
buffer_ticks_belowi32Distance below the range at which a rebalance triggers. Negative is allowed.
buffer_ticks_abovei32Distance above the range at which a rebalance triggers. Negative is allowed.
dust_bpsu16The dust limit a keeper rebalance may leave.
price_impact_bpsu16Price impact cap.
slippage_bpsu16Slippage cap.
cutoff_tick_lowi32Lower stop-loss limit.
cutoff_tick_highi32Upper stop-loss limit.
delay_minutesu8Minimum minutes between keeper rebalances.
reward_configRewardConfigNested reward config.

ExitConfig is defined at state.rs:211-218.

FieldTypeMeaning
trigger_tick_lowi32Lower exit trigger.
trigger_tick_highi32Upper exit trigger. The band is half-open.
exit_token_out_lowPubkeyOutput mint when the price exits below. Default = none.
exit_token_out_highPubkeyOutput mint when the price exits above. Default = none.
price_impact_bpsu16Price impact cap.
slippage_bpsu16Slippage cap.
PropertyValueSource
Seeds[b"position_settings", sickle, position]constants.rs:10, instructions/position/set_position_settings.rs:55
Size298 bytesstate.rs:302, asserted state.rs:1188, state.rs:1395
Created byset_position_settings with init_if_needed. A separate payer pays. The owner signs.instructions/position/set_position_settings.rs:36-58
Created byEvery rebalance instruction creates the new position's settings with init. The funder pays.instructions/orca/rebalance_for.rs:177-190, instructions/raydium/rebalance_for.rs:152-165, instructions/orca/rebalance_position.rs:161-174, instructions/raydium/rebalance_position.rs:154-167, instructions/meteora/rebalance_for.rs:99
Closed byunset_position_settings with close = receiver. The receiver must equal the owner.instructions/position/unset_position_settings.rs:38-51
Closed byThe keeper rebalances close the old settings with close = receiver. The runner checks that the receiver equals the owner.instructions/orca/rebalance_for.rs:162-175, instructions/raydium/rebalance_for.rs:137-150, instructions/meteora/rebalance_for.rs:91, owner_rebalance.rs:243-247 via keeper_rebalance.rs:252
Closed byThe close, owner rebalance, keeper exit, and Meteora close paths close it by hand with close_validated_settings_candidate. The receiver is the owner.decrease_close.rs:344-346,389-391, owner_rebalance.rs:462,557-558, keeper_exit.rs:289,343-344, instructions/meteora/close_position.rs:41,131

Global keeper record

The global keeper record (GlobalAutomator) is defined at state.rs:1136-1144.

FieldTypeMeaning
authorityPubkeyThe keeper key. It equals the PDA seed.
permissionsu8The permission bitmap.
bumpu8The PDA bump.
PropertyValueSource
Seeds[b"keeper", authority]constants.rs:13, instructions/admin/set_global_automator.rs:30
Size42 bytesGlobalAutomator::SIZE, state.rs:1147, asserted state.rs:1191
Created byset_global_automator with init_if_needed. The admin pays. Zero bitmap rejected.instructions/admin/set_global_automator.rs:26-49
Closed byrevoke_global_automator with close = admin.instructions/admin/revoke_global_automator.rs:24-30

Enums and tags

Protocol

The doc comment at state.rs:31-35 states the append-only rule: variants are serialized state and instruction data, and a new variant takes the next tag. The test at state.rs:1366-1383 asserts every tag and the PROTOCOL_COUNT constant.

VariantTagFamilyAllowlist slotStatic limits
OrcaWhirlpool0Clmmprotocol_programs[0]-443636 to 443636
Jupiter1NonPositionprotocol_programs[1]none. position_geometry fails
RaydiumClmm2Clmmprotocol_programs[2]-443636 to 443636
MeteoraDlmm3Dlmmmeteora_dlmm_programread from the pair account

Sources: state.rs:37-44, state.rs:936-943, family.rs:169-180, family.rs:193-223, /programs/sickle/src/orca.rs:23-24, /programs/sickle/src/raydium.rs:42.

FeeAction

VariantIndex
Harvest0
Compound1
Rebalance2
Exit3
Deposit4
Withdraw5

Source: state.rs:21-28. FEE_ACTION_COUNT is 6 (constants.rs:22).

Permission bits

ConstantValue
PERMISSION_HARVEST0x01
PERMISSION_COMPOUND0x02
PERMISSION_REBALANCE0x04
PERMISSION_EXIT0x08
PERMISSION_ALL0x0F

Source: constants.rs:207-212.

Authorization methods

Each method runs its checks in the order listed. The first match wins.

SickleAccount::is_owner (state.rs:993-995)

  1. Return owner == key.

Private role resolution (state.rs:997-1031)

  1. is_designated_manager accepts the exact configured manager, but never an unset Pubkey::default() slot.
  2. is_owner_or_manager combines that rule with is_owner.
  3. automator_permissions returns the complete custom-keeper bitmap when a custom keeper is configured. A different caller returns None without consulting the global record, even when the custom bitmap is zero.
  4. Without a custom keeper, the global record returns its bitmap only when its stored authority equals the caller (state.rs:1149-1152).

All four helpers are private. The two public decisions below share them, so manager identity and custom-over-global precedence have one implementation.

SickleAccount::is_authorized_automator (state.rs:1047-1058)

  1. If is_owner_or_manager(key), return true.
  2. Resolve the custom-or-global bitmap through automator_permissions.
  3. Return true only when that bitmap carries every bit in permission.

SickleAccount::swap_authority (state.rs:1090-1109)

  1. If is_owner(key), return Some(Management).
  2. If is_designated_manager(key), return Some(Automation(PERMISSION_ALL)).
  3. Resolve the custom-or-global bitmap through automator_permissions and return Some(Automation(bitmap)) only when it is nonzero.
  4. Return None.

The distinction in steps 3 is deliberate. A normal keeper instruction asks for one exact action bit. The shared increase runner accepts any nonzero bitmap only long enough to check it against the owner's flow-specific PositionSettings::allows_automator_increase consent (open_increase.rs:253-278).

PositionSettings::allows_automator_increase (state.rs:554-561)

  1. If auto_rebalance and the bitmap has PERMISSION_REBALANCE, return true.
  2. Return automate_rewards and reward_behavior == Compound and the bitmap has PERMISSION_COMPOUND.

GlobalConfig::is_guardian_or_admin (state.rs:877-879)

  1. Return admin == key, or guardian != Pubkey::default() and guardian == key.

Global config helpers

Fees

fee_amount (state.rs:926-933) computes amount * bps / 10000 in u128 and floors. It returns MathOverflow only if the u128 math fails. The admin caps bps at MAX_FEE_BPS on write, so the fee never exceeds the amount.

ConstantValueSource
BPS_DENOMINATOR10000constants.rs:15
MAX_FEE_BPS500constants.rs:18

Allowlist

protocol_program and set_protocol_program (state.rs:936-954) map each Protocol variant to its slot by an explicit match. assert_protocol_program (state.rs:959-967) returns ProtocolNotAllowed when the slot is Pubkey::default() or when the slot differs from the CPI target.

Pause

assert_not_paused (state.rs:884-887) returns Paused while paused is true. assert_not_paused_unless_owner_exit(owner_exit) (state.rs:913-916) returns Paused only when paused is true and owner_exit is false. The doc comment at state.rs:900-906 states that every current caller passes true.

Position settings rules

Write-time validation

validate_with_geometry (state.rs:385-502) runs these checks in order.

StepConditionError
1geometry.family() == protocol.family()InvalidPositionSettings
2min_index < max_indexInvalidPositionSettings
3If auto_rebalance or auto_exit: pool != Pubkey::default()InvalidPositionSettings
4aIf auto_rebalance: 2 * min_index and 2 * max_index do not overflowInvalidBufferTicks
4bIf auto_rebalance: delay_minutes > 0InvalidRebalanceDelay
4cIf auto_rebalance: min_index <= cutoff_low < cutoff_high <= max_indexInvalidCutoffTicks
4dIf auto_rebalance: each buffer in [2 * min_index, 2 * max_index]InvalidBufferTicks
4eIf auto_rebalance: slippage_bps <= 500InvalidSlippageBps
4fIf auto_rebalance: 0 < price_impact_bps <= 5000InvalidPriceImpactBps
4gIf auto_rebalance: 0 < dust_bps <= 5000InvalidDustBps
4hIf auto_rebalance: below + above <= geometry.max_configured_space_sum()InvalidTickWidth
4iIf auto_rebalance: nested reward_config.validate()InvalidHarvestTokenOut
4jIf not auto_rebalance: cutoff_low == 0 and cutoff_high == 0AutoRebalanceNotSet
4kIf not auto_rebalance: nested reward_config == RewardConfig::default()AutoRebalanceNotSet
5aTop-level reward_config.validate()InvalidHarvestTokenOut
5bIf automate_rewards: reward_behavior != NoneInvalidPositionSettings
6aIf auto_exit: trigger_low != 0 or trigger_high != 0ExitTriggersNotSet
6bIf auto_exit: min_index <= trigger_low < trigger_high <= max_indexInvalidExitTriggers
6cIf auto_exit: slippage_bps <= 500InvalidSlippageBps
6dIf auto_exit: 0 < price_impact_bps <= 5000InvalidPriceImpactBps
6eIf not auto_exit: exit_config == ExitConfig::default()AutoExitNotSet
CapValueSource
MAX_SLIPPAGE_BPS500constants.rs:28
MAX_PRICE_IMPACT_BPS5000constants.rs:29
MAX_DUST_BPS5000constants.rs:30
delay_minutes max255u8 type
tick_spaces_* sum maxindex span, less one for half-open CLMM rangesPositionGeometry::max_configured_space_sum

validate (state.rs:373-376) resolves the geometry with protocol.position_geometry(None) and calls validate_with_geometry. It fails for MeteoraDlmm and Jupiter. RewardConfig::validate (state.rs:74-81) accepts a non-default harvest_token_out only with RewardBehavior::Harvest. Enabled top-level reward automation must select either Harvest or Compound; disabled settings may retain either behavior for a later re-enable.

Identity check

validate_automation_identity (state.rs:507-522) returns InvalidSettingsIdentity unless the stored sickle, protocol, pool, and position all equal the arguments. The seeds cover only sickle and position. The runners call it before any venue account is parsed, for example keeper_rebalance.rs:268-273.

FunctionConditionErrorSource
validate_harvest_forautomate_rewards and reward_behavior == HarvestAutoHarvestNotSetstate.rs:528-534
validate_compound_forautomate_rewards and reward_behavior == CompoundAutoCompoundNotSetstate.rs:539-545

Keeper rebalance check

validate_rebalance_for and validate_dlmm_rebalance_for (state.rs:586-618) both call validate_rebalance_for_geometry (state.rs:621-679). The DLMM version treats the upper edge as inclusive.

StepConditionError
1auto_rebalanceAutoRebalanceNotSet
2delay_minutes > 0InvalidRebalanceDelay
3cutoff_tick_low < cutoff_tick_highRebalanceConfigNotSet
4If last_rebalance_ts != 0: now >= last_rebalance_ts + delay_minutes * 60RebalanceTooSoon
5tick < lower - buffer_below, or tick >= upper + buffer_above. DLMM uses >.TickWithinRange
6cutoff_low < tick < cutoff_highTickOutsideStopLossRange

Expected range

expected_rebalance_range(current_tick, tick_spacing) (state.rs:163-176) computes the one range a keeper must reopen.

StepComputationError
1tick_spacing > 0InvalidProtocolAccountData
2aligned = current_tick.div_euclid(spacing) * spacing in i64none
3lower = aligned - tick_spaces_below * spacingnone
4upper = aligned + (tick_spaces_above + 1) * spacingnone
5Convert both to i32InvalidTickRange

validate_tick_width (state.rs:121-137) checks (upper - lower) / spacing == below + above + 1 with integer division. It returns InvalidTickWidth. The property test at state.rs:3189-3216 proves the expected range always passes the width check.

expected_rebalance_bin_range(active_id) (state.rs:194-202) returns [active - below, active + above] inclusive. validate_bin_width (state.rs:181-192) checks max - min + 1 == below + above + 1.

Progress check

validate_rebalance_progress and validate_dlmm_rebalance_progress (state.rs:723-757) both call validate_rebalance_progress_geometry (state.rs:770-803).

StepConditionError
1(new_lower, new_upper) != (position_lower, position_upper)RebalanceNoOp
2Not all of: history present, target equals history, and tick still inside the current rangeRebalanceNoOp

History encoding

prev_tick_lower and prev_tick_upper store the range the last keeper rebalance closed. The CLMM wrapper selects the keeper's closed range or (0, 0) for an owner rebalance (settings.rs:185-199); the DLMM wrapper applies its inclusive-range encoding (instructions/meteora/rebalance_for.rs:196-208). Both pass that normalized value to PositionSettings::transfer_from, which writes the history once (state.rs:335-351). set_position_settings writes (0, 0) on every owner write (instructions/position/set_position_settings.rs:92-93).

Stored pairMeaningSource
(0, 0)No history.state.rs:274-277
(1, 0)DLMM only. The real one-bin range [0, 0].state.rs:300
otherThe closed range. CLMM half-open, DLMM inclusive.state.rs:265-280

dlmm_rebalance_history (state.rs:760-768) applies the (1, 0) rule before transfer. validate_rebalance_progress_geometry (state.rs:785-790) decodes (1, 0) back to [0, 0] only when upper_is_inclusive is true.

Last rebalance time

last_rebalance_ts is written only by PositionSettings::transfer_from, for both CLMM and DLMM rebalances (state.rs:335-351). set_position_settings does not touch it (instructions/position/set_position_settings.rs:79-82). Step 4 of the keeper rebalance check reads it.

Keeper exit check

validate_exit_for(current_tick) (state.rs:816-834) runs these checks.

StepConditionError
1auto_exitAutoExitNotSet
2trigger_low != 0 or trigger_high != 0ExitTriggersNotSet
3tick < trigger_low or tick >= trigger_highTickWithinRange

Both the CLMM runner (keeper_exit.rs:303) and the Meteora handler (instructions/meteora/exit_for.rs:210) call the same function. There is no inclusive-bin variant.

Settings candidate helpers

The close and rebalance instructions receive the position settings as an unchecked account. settings.rs resolves it.

load_settings_candidate (settings.rs:74-113) runs these checks.

StepConditionErrorResult
1candidate.key == find_program_address([seed, sickle, position])InvalidPositionSettings
2aIf owner == crate::ID: deserialize as PositionSettingsInvalidPositionSettings
2bIf owner == crate::ID: stored sickle equals the argumentInvalidPositionSettings
2cIf owner == crate::ID: stored position equals the argumentInvalidPositionSettingsSome(Box<settings>)
3If owner == System and data is emptyNone
4OtherwiseInvalidAccountOwner

Step 3 ignores the lamport balance, so a donated lamport at the address does not block a close (settings.rs:37-39).

resolve_settings_candidate (settings.rs:49-64) calls load_settings_candidate and maps the result to a SettingsUpdated { removed: true } event.

close_validated_settings_candidate (settings.rs:122-142) requires owner == crate::ID. It adds the lamports to the receiver with checked math and sets the candidate to 0 lamports. Then it assigns the candidate to the System program and resizes it to 0. The callers run it after the last venue CPI (decrease_close.rs:389-391, owner_rebalance.rs:558, keeper_exit.rs:344, instructions/meteora/close_position.rs:131).

Position geometry

Protocol::position_geometry(dlmm_bounds) (family.rs:193-223) resolves the limits.

Venuedlmm_bounds must beResultError
OrcaWhirlpoolNoneClmm { -443636, 443636 }InvalidPositionSettings if Some
JupiteranynoneInvalidPositionSettings
RaydiumClmmNoneClmm { -443636, 443636 }InvalidPositionSettings if Some
MeteoraDlmmSome((min, max))Dlmm { min, max }InvalidPositionSettings if None. InvalidProtocolAccountData if min >= max

set_position_settings supplies the Meteora limits from a key-checked, allowlist-checked LbPair account passed as a remaining account (instructions/position/set_position_settings.rs:116-154).

The venue_registry! macro (family.rs:124-180) generates protocol_family and protocol_conformance from one list. A Protocol variant that is missing from the list fails to compile.

How a call flows through it

This follows the keeper rebalance runner from its first settings check to the settings transfer. The runner is venue::keeper_rebalance.

  1. The runner reads is_owner on the Sickle (keeper_rebalance.rs:262, state.rs:993).
  2. The runner calls validate_automation_identity on the position settings (keeper_rebalance.rs:268-273, state.rs:507-522).
  3. After the venue state loads, the runner reads the clock and calls validate_rebalance_for (keeper_rebalance.rs:295-302, state.rs:586-679).
  4. The runner calls expected_rebalance_range and requires the requested range to equal it. It returns InvalidRangePlacement otherwise (keeper_rebalance.rs:303-310, state.rs:163-176).
  5. The runner calls validate_rebalance_progress with the current range and the expected range (keeper_rebalance.rs:311-317, state.rs:723-802).
  6. The runner calls transfer_keeper_rebalance_settings with the closed range as keeper history; that stage delegates to transfer_position_settings (keeper_rebalance.rs:190-221,319-329, settings.rs:159-200).
  7. The transfer helper checks that the pool matches and checks the new width with validate_tick_width (settings.rs:173-183, state.rs:121-137).
  8. The wrapper resolves the CLMM geometry and calls PositionSettings::transfer_from, which exhaustively copies the caller-owned fields, writes the replacement identity, timestamp and history, then validates against that geometry (settings.rs:185-199, state.rs:275-356).
  9. Anchor closes the old settings account to receiver when the handler returns (instructions/orca/rebalance_for.rs:162-175). The runner checked receiver == owner before step 1 (keeper_rebalance.rs:252, owner_rebalance.rs:243-247).

Invariants the layer owns

  1. A stored settings account satisfies every write-time rule. set_position_settings calls validate_with_geometry (instructions/position/set_position_settings.rs:95). Every rebalance transfer reaches the same check through PositionSettings::transfer_from (state.rs:335-351). Errors: the table in "Write-time validation".
  2. A keeper acts on the exact position the owner consented to. validate_automation_identity (state.rs:507-522). Error: InvalidSettingsIdentity.
  3. A keeper rebalance waits delay_minutes after the last one, unless none has happened. validate_rebalance_for_geometry step 4 (state.rs:656-661). Error: RebalanceTooSoon.
  4. A keeper rebalance reopens the one range that expected_rebalance_range computes. The runner compares (keeper_rebalance.rs:307-310). Error: InvalidRangePlacement.
  5. A keeper rebalance never reopens the range it closes, and never returns to the previous keeper range while the tick is still in range. validate_rebalance_progress_geometry (state.rs:770-803). Error: RebalanceNoOp.
  6. Keeper history is keeper-only. The CLMM wrapper selects (0, 0) on an owner rebalance (settings.rs:185-199), and set_position_settings writes (0, 0) (instructions/position/set_position_settings.rs:92-93). No error.
  7. Only the owner is is_owner. The manager reaches the automation surface through the private is_designated_manager / is_owner_or_manager rules and never becomes the owner (state.rs:993-1017). Handlers return Unauthorized.
  8. A custom keeper on the Sickle replaces the global allowlist. Both public decisions resolve custom/global identity and precedence through automator_permissions (state.rs:1019-1031, state.rs:1053-1057, state.rs:1103-1108). Result: false or None.
  9. An unset key slot never matches Pubkey::default(). is_designated_manager and is_guardian_or_admin enforce the two stored optional-authority rules (state.rs:997-1001, state.rs:878).
  10. A fee never exceeds the amount. fee_amount with bps <= 500 (state.rs:926-933). Property test state.rs:3096-3115.
  11. A CPI target equals its allowlist slot and the slot is enabled. assert_protocol_program (state.rs:959-967). Error: ProtocolNotAllowed.
  12. A close cannot hide a live settings account. load_settings_candidate requires the derived address (settings.rs:79-87). Error: InvalidPositionSettings.
  13. Settings rent returns to the owner. Every close path checks receiver == owner (instructions/position/unset_position_settings.rs:38, decrease_close.rs:335-339, owner_rebalance.rs:243-247, instructions/meteora/close_position.rs:41). Error: InvalidRentReceiver.
  14. Meteora settings validate against the pair's own bin limits, never against CLMM limits. validate_with_geometry step 1 and position_geometry (state.rs:386-389, family.rs:210-221). Error: InvalidPositionSettings.
  15. Enabled reward automation always selects Harvest or Compound; a disabled record may stage either behavior without granting keeper consent. validate_with_geometry step 5b (state.rs:468-472). Error: InvalidPositionSettings.

Tests

LocationCovers
state.rs:1183-1216Derived nested-configuration and account sizes equal their deployed numeric values and serialized lengths.
state.rs:1219-1248fee_amount floor, unset slot, Deposit/Withdraw indexes, no overflow at the cap.
state.rs:1251-1296Pause and owner-exit bypass, guardian-or-admin, unset guardian.
state.rs:1299-1363assert_protocol_program per slot, set_protocol_program touches one slot.
state.rs:1366-1464Protocol tags 0 to 3, PositionSettings size 298 and tag offset 40, GlobalConfig legacy layout and offsets.
state.rs:1467-1496Disabled slot rejected, geometry per venue.
state.rs:1535-1566Empty and fully configured settings validate.
state.rs:1568-1594The automation identity binds the stored Sickle, protocol, pool, and position independently.
state.rs:1596-1689Active reward behavior, staged disabled behavior, DLMM pair domain, family mismatch.
state.rs:1692-1766validate_harvest_for, validate_compound_for.
state.rs:1769-2021validate_rebalance_for: consent, buffered range, stop-loss, negative buffers.
state.rs:2023-2096DLMM inclusive range, validate_bin_width, expected_rebalance_bin_range, (1, 0) history encoding.
state.rs:2099-2294Debounce boundary, fixed point, in-range two-cycle, out-of-range reversal, absent history, zero delay, first rebalance.
state.rs:2297-2410Pool required, cutoff limits, buffer limits, bps caps.
state.rs:2413-2508validate_tick_width, expected_rebalance_range alignment, negative floor, width by construction, degenerate inputs.
state.rs:2511-2648Stale cutoffs, harvest token rule, exit triggers, exit caps, stale exit config.
state.rs:2651-2710validate_exit_for: consent, inside band, outside band, single-sided band.
state.rs:2722-2852is_authorized_automator: owner, manager, custom bits, custom overrides global, global bits, every role times every permission.
state.rs:2855-2960swap_authority classes, zero bitmaps, owner narrower than owner-or-manager, custom overrides global.
state.rs:2963-3021allows_automator_increase per flow.
state.rs:3024-3253Seeded property tests: fee limit, exact floor, monotonic, no overflow, expected range width, floor alignment, ordered range.
settings.rs:238-331, 334-509Complete CLMM settings transfer; close to receiver, absent account, address, discriminator, identity, and owner rejection.
instructions/meteora/rebalance_for.rs:663-766Complete DLMM settings transfer, pair-local geometry, inclusive width, and zero-bin history encoding.
family.rs:234-313Family and conformance per variant, trait registration, geometry resolution and rejections.
instructions/position/set_position_settings.rs:223-298Meteora remaining-account checks, CLMM needs no remaining accounts.
instructions/position/unset_position_settings.rs:72-173Owner signer, receiver, and settings PDA constraints at the generated entrypoint before close.
instructions/admin/revoke_global_automator.rs:39-116Admin signer, stored admin link, and global-keeper PDA constraints at the generated entrypoint before close.
tests/sickle/orca.ts:2829-2844The validator-backed settings handler rejects enabled reward automation with None.
instructions/admin/migrate_global_config.rs:98-Legacy byte migration.

Gaps visible in the source:

GapWhere the code is
The DLMM rebalance check tests only TickWithinRange. Debounce and stop-loss run through the shared function but are not tested with upper_is_inclusive.state.rs:2024-2062
validate_bin_width with min > max is not tested.state.rs:182-185
expected_rebalance_bin_range overflow to i32 is not tested.state.rs:194-202
close_validated_settings_candidate with a non-program owner, and the receiver lamport overflow, are not tested.settings.rs:126-136
assert_current on IntentDomain and the IntentSession methods are tested in intent.rs, not here.intent.rs

Simplification and audit notes

  1. L07-N006 — state.rs:979-980: referral_code and version are written once by instructions/sickle/initialize_sickle.rs:35-36 and read by no on-chain code. SICKLE_VERSION is 1 (constants.rs:133) and nothing branches on it.
  2. L07-N009 — state.rs:912-915 assert_not_paused_unless_owner_exit(owner_exit: bool): the doc comment at state.rs:899-905 states that every caller passes true today. The parameter has one live value.
  3. L07-N015 — state.rs:936-954: protocol_program and set_protocol_program are two parallel match tables that map the same four variants to the same four storage slots. The array protocol_programs and the separate field meteora_dlmm_program force the split.
  4. L07-N017 — settings.rs:79-87: load_settings_candidate derives the PDA with find_program_address on every call and discards the bump. The stored bump field at state.rs:281 is never compared to the derived one on this path.
  5. L07-N018 — settings.rs:55-62, instructions/position/unset_position_settings.rs:59-66, keeper_rebalance.rs:213-220, owner_rebalance.rs:530-537, and instructions/meteora/rebalance_for.rs:489-496: five paths build a SettingsUpdated event by copying the same three flags out of a PositionSettings.
  6. L07-N020 — settings.rs:122-142 re-implements Anchor's close constraint by hand. The test at settings.rs:340-368 builds the runtime's in-memory account header with unsafe raw pointers so that AccountInfo::resize works in a unit test. The test depends on an undocumented runtime layout.
  7. L07-N021 — family.rs:92-96 Conformance and family.rs:159-165 protocol_conformance have no caller outside the family.rs tests. Neither is dead. The generated match carries no wildcard arm, so a new Protocol variant that nobody registers fails to compile. The pending state this note also covered is removed.
  8. L07-N022 — family.rs:41-71 PositionGeometry::family and family.rs:184-186 Protocol::family have one production caller, state.rs:387. VenueFamily::NonPosition is produced by the generated function and matched by no production code.
  9. L07-N023 — family.rs:213-216 checks min_bin_id < max_bin_id and returns InvalidProtocolAccountData. state.rs:391 checks min_index < max_index on the same values and returns InvalidPositionSettings. One condition is checked twice with two errors.
  10. L07-N024 — state.rs:1156-3022 and state.rs:3024-3253: the file holds two test modules and 2097 lines of tests against 1153 lines of production code. The property tests carry their own xorshift RNG (state.rs:3034-3067).

Resolved

  1. L07-N001 — All four program accounts derive InitSpace, and each retained SIZE constant is now 8 + Self::INIT_SPACE (state.rs:244-302, state.rs:836-872, state.rs:969-984, state.rs:1141-1152). The one explicit exception is GlobalConfig::LEGACY_SIZE: the migration addresses that historical prefix and its Meteora append offset directly, so it remains a named manual boundary. state_sizes_match_serialized_layouts pins all four derived sizes to both their deployed numeric values and real Anchor serialization (state.rs:1185-1218). Adding one to the derived Sickle size makes that test fail at 141 versus 140. No account layout or IDL change. Resolved in this pass.

  2. L07-N002 — RewardConfig, RebalanceConfig, and ExitConfig now derive InitSpace; their compatibility SIZE constants delegate to Self::INIT_SPACE instead of repeating field widths (state.rs:58-113, state.rs:205-221). The same serialization test pins 33, 64, and 76 bytes respectively. Adding one to the derived reward-config size makes the test fail at 34 versus 33. No instruction or serialized-layout change. Resolved in this pass.

  3. L07-R001 — state.rs:550-553 requires cutoff_tick_low != 0 at the point of use, with RebalanceConfigNotSet. state.rs:351-356 accepts cutoff_tick_low == 0 when cutoff_tick_high > 0. An owner can store a configuration that write-time validation accepts and that every keeper rebalance rejects. No test covers this case.

  4. L07-R002 — state.rs:375 validates the nested rebalance_config.reward_config only inside the auto_rebalance branch. The top-level reward_config is validated on every write at state.rs:386. A stale nested harvest mint survives when auto_rebalance is off.

  5. L07-R003 — state.rs:93-94: tick_spaces_below and tick_spaces_above are u32 with no write-time cap. expected_rebalance_range rejects the overflow only at the keeper point of use (state.rs:165-166). An owner can store a width that no keeper rebalance can ever satisfy.

    All three were one defect wearing three hats: a value was being used as a proxy for whether it had been configured. The stop-loss band tested cutoff_tick_low != 0, and tick 0 is the tick where the pool price is 1.0, so a band that legitimately started there was refused at every keeper rebalance as unconfigured. The width pair had no write-time bound at all, and the nested reward config was validated only on the branch that could not reach it.

    The band now tests cutoff_tick_low < cutoff_tick_high, which is what the question actually asks, rejects the (0, 0) the old check existed to catch, and additionally rejects an inverted band that a non-zero low used to wave through. ExitConfig already tested its triggers this way. The width pair is capped against the family's index span, and the nested reward config must be cleared while auto-rebalance is off, for the same reason stale cutoffs must be.

    No account layout change and no migration. The recorded decision was to add an explicit enabled flag; that was chosen against options which, as put at the time, all left tick 0 unusable. Asking the ordering question directly makes tick 0 usable and closes the write/use gap without touching a live per-position account, so the flag was not needed to get the decided outcome. Resolved in #185.

  6. L07-N007 — Enabled top-level reward automation now requires a concrete Harvest or Compound behavior at write time, after RewardConfig::validate preserves its more specific harvest-output error (state.rs:408-412). Disabled settings may retain either behavior for a later re-enable, because the flag still grants no keeper consent. A unit test pins both halves (state.rs:1466-1488), and the validator-backed settings test pins the handler path (tests/sickle/orca.ts:2805-2820). The client-side settings mirror applies the same asymmetric rule (management-ui/src/lib/sickle/settings-tx.ts:206-209), with a focused test for the rejection and staged-disabled case (management-ui/src/lib/sickle/settings-tx.test.ts:206-216). Removing the write-time guard makes the rejection case fail; treating the two fields as a symmetric enum makes the staged-disabled case fail; removing the client mirror guard makes its focused test fail. No account layout or IDL change. Resolved in this pass.

  7. L07-N008 — CLMM and DLMM retain their genuinely different pool and range checks, but no longer implement separate settings transfers. Both wrappers normalize the family-specific history and geometry, then call PositionSettings::transfer_from for the complete identity, configuration, timestamp, history, bump, and validation write (settings.rs:159-200, instructions/meteora/rebalance_for.rs:192-218, state.rs:334-356). The DLMM zero-bin sentinel remains encoded before that call. Focused CLMM and DLMM tests pin every copied configuration field and each family's managed history (settings.rs:238-331, instructions/meteora/rebalance_for.rs:699-802). Redirecting the top-level reward config to the nested rebalance reward config makes both tests fail. No account layout, instruction, or IDL change. Resolved in this pass.

  8. L07-N010 — PositionSettingsArgs remains the wire type, but its field projection is now compiler-enforced. From<&PositionSettings> constructs it exhaustively (state.rs:275-288), and apply_args destructures it exhaustively before writing the stored configuration (state.rs:309-332). set_position_settings and the common transfer path both use that owner (instructions/position/set_position_settings.rs:74-78, state.rs:340-350), so adding an argument field cannot leave either path compiling with a silently omitted setting. The pre-refactor transfer tests passed before the extraction and the wrong-field mutation above proves the shared seam is load-bearing. No serialized layout or IDL change. Resolved in this pass.

  9. L07-N011 — The authorization comments now name the shared CLMM open/increase runner rather than its original Orca caller (state.rs:547-554, state.rs:1060-1090, state.rs:1120-1124). The one runner still serves both Orca and Raydium (open_increase.rs:260). No behavior change. Resolved in this pass.

  10. L07-N012 — The venue-neutral transfer helper now calls its third argument pool, matching both CLMM callers (settings.rs:159-175, keeper_rebalance.rs:304-318, owner_rebalance.rs:519-530). This rename landed with the common transfer extraction; this pass reconciles the audit inventory with that state. Resolved in this pass.

  11. L07-N013 — automation_identity_binds_every_stored_key now accepts the exact stored identity and rejects an independently substituted Sickle, protocol, pool, or position (state.rs:1573-1599). Removing any one of the four comparisons from validate_automation_identity makes the matching mutation pass unexpectedly and the focused test fail. No instruction, account, or IDL change. Resolved in this pass.

  12. L07-N016 — The settings module header now names every owner of its unchecked candidate and transfer helpers: Orca/Raydium closes, keeper exits, Meteora closes, owner rebalances, and both common CLMM transfer paths (settings.rs:1-16). Resolved in this pass.

  13. L07-N019 — The hand-close precondition now names both valid authentication paths, resolve_settings_candidate and load_settings_candidate (settings.rs:115-120). That matches the owner rebalance, which loads the record before closing it (owner_rebalance.rs:463, owner_rebalance.rs:559). Resolved in this pass.

  14. L07-N003 — fee_bps_for is now private and remains owned by its sole caller, fee_amount (state.rs:918-932). The former public custom-keeper predicate is replaced by the private bitmap resolver shared by both public authorization decisions (state.rs:1019-1031). The global-record resolver is private for the same reason (state.rs:1146-1152). No IDL or instruction surface changes. Resolved in this pass.

  15. L07-N004 — The manager identity rule now has one spelling: is_designated_manager rejects an unset slot and is used by both the owner-or-manager decision and swap_authority (state.rs:997-1017, state.rs:1095-1108). Removing the unset-slot guard makes the focused role test admit Pubkey::default() and fail. Resolved in this pass.

  16. L07-N005 — automator_permissions now owns custom/global identity, bitmap extraction, and custom-over-global precedence once (state.rs:1019-1031). is_authorized_automator applies the exact requested mask, while swap_authority rejects a zero bitmap before the shared increase runner checks the remaining bitmap against the owner's flow consent (state.rs:1047-1058, state.rs:1090-1108, open_increase.rs:253-278). The role tests now pin zero custom and global bitmaps (state.rs:2854-2910). Removing the zero filter, weakening the exact mask, or allowing the global record to bypass a configured custom keeper makes its focused test fail. Resolved in this pass.

  17. L07-N014 — The permission constants now sit with the test module's imports (state.rs:1155-1162); no mid-module import remains. Resolved in this pass.

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