Spec — FeeCollector Program
Owner: anchor-engineer
Depends on: SAEP Token-2022 mint with TransferFee + TransferHook + PermanentDelegate + Pausable extensions (M3 spec — FeeCollector deploys pre-mint on devnet against a placeholder Token-2022 mint and is rewired to the real mint at M3 via meta-governance); NXSStaking (staker-share distribution consumer + slash-intake source); GovernanceProgram (TreasurySpend executor + param authority); Squads multisig v4 (6-of-9 meta-governance for grant-pool payouts + authority handover; 4-of-7 program council for upgrades).
Blocks: M3 SAEP mint bootstrap (TransferHook callback + PermanentDelegate delegate both resolve to a program PDA; mint init cannot commit the extension fields until the PDA addresses are known); M2 GovernanceProgram TreasurySpend execution path (program_registry entry depends on FeeCollector being live); NXSStaking staker-rewards crank (pulls from FeeCollector's staker pool); AgentRegistry / DisputeArbitration / GovernanceProgram slash-destination consumers (all CPI into FeeCollector's slash-intake vault).
References: backend PDF §1.3 (FeeCollector owns TransferFee withdrawal authority + PermanentDelegate after mint handover + TransferHook callback program; burn / staker-share / grant-share / treasury split governance-set; Pausable Phase 3 emergency council 4-of-7), §2.1 (CU targets — process_epoch 80k / claim_staker 20k / execute_burn 30k / collect_fees 40k), §2.6 (deployment + upgrade table — 7d standard timelock; Squads 4-of-7 authority), §4.3 (deploy order — FeeCollector lands after CapabilityRegistry + GovernanceCore, before AgentRegistry so slash rails can name the program; 48h devnet timelock at init), §5.1 (Security checklist: re-entrancy on harvest → distribution pipeline, Token-2022 extension safety for TransferHook / TransferFee / PermanentDelegate, distribution-math integer overflow, burn irreversibility, authorization boundaries for hook-allowlist vs governance vs upgrade authorities), §5.2 (multisig 4-of-7 + 6-of-9 split + signer geo-distribution + HSM).
Goal
The single fee + slash sink for SAEP. Token-2022 TransferFee withdrawals from the SAEP mint funnel here via harvest_withheld_tokens_to_mint + withdraw_withheld_tokens_from_mint (both CPI'd from this program, signed by the TransferFee authority PDA which this program owns). The program also passively receives slash tokens from NXSStaking / AgentRegistry / DisputeArbitration (transfer_checked to the slash-intake ATA) and forfeit proposer collateral from GovernanceProgram.
Accumulated tokens are partitioned per epoch across four sinks per §1.3:
- Burn (default 1000 bps / 10%) — CPI'd via Token-2022
burn_checkedsigned by the PermanentDelegate PDA; SAEP mint authority isNonepost-handover, so PermanentDelegate is the only legal burn path. - Staker share (default 5000 bps / 50%) — routed to the per-epoch staker distribution vault. Off-chain cranker computes per-staker claim entitlements against the NXSStaking snapshot at
epoch_snapshot_id, commits a merkle root viacommit_distribution_root, stakers pull viaclaim_stakerwith a merkle inclusion proof. - Grant share (default 2000 bps / 20%) — routed to the grant-pool ATA; outflow is meta-governance only (Squads 6-of-9 + GovernanceProgram
TreasurySpendCPI, whichever the grant governs). - Protocol treasury (default 2000 bps / 20%) — routed to the treasury ATA; outflow is GovernanceProgram
TreasurySpendCPI (standard 7d timelock).
The bps quadruple MUST sum to 10_000 exactly at every set_distribution_params write. Revised defaults land via GovernanceProgram CPI; the 4-of-7 program council can only set the 10% per-bucket hard ceilings and the authority routing.
FeeCollector also exposes the SPL TransferHookInterface::execute callback. The hook is advisory-only at M2 (it asserts the mint / source / destination owner types and returns Ok — no burn / freeze / deny inside the hook, which would violate Token-2022's "hook cannot fail except for structural reasons" contract). Rate-limit / compliance hooks are flagged as M4+ post-audit.
Every transition is signed, seeded, event-logged, and TransferHook-aware so the indexer can replay any fee / slash / distribution event deterministically and the portal can surface live fee accrual + per-staker claims.
State
FeeCollectorConfig PDA — singleton
- Seeds:
[b"fee_config"] - Fields:
authority: Pubkey— Squads 4-of-7 program council (per §2.6); param routing + authority handovermeta_authority: Pubkey— Squads 6-of-9 meta-governance; adjusts distribution bps + bucket hard ceilingsgovernance_program: Pubkey— GovernanceProgram CPI identity (TreasurySpend executor + params when routed via governance)nxs_staking: Pubkey— NXSStaking CPI identity (snapshot-root lookup for staker-share weights; slash source)agent_registry: Pubkey— AgentRegistry CPI identity (slash source; proposer-collateral forfeit destination)dispute_arbitration: Pubkey— DisputeArbitration CPI identity (slash source)emergency_council: Pubkey— Squads 4-of-7 (per §1.3 Pausable extension authority; invokesset_paused)saep_mint: Pubkey— Token-2022 SAEP mint (TransferFee + TransferHook + PermanentDelegate + Pausable)transfer_fee_authority: Pubkey— TransferFee withdrawal authority PDA[b"fee_auth"]; set on the mint at init (M3)permanent_delegate: Pubkey— PermanentDelegate PDA[b"perm_delegate"]; set on the mint at init (M3)hook_authority: Pubkey— TransferHook program authority; not a delegate — only exists to route the advisory hook executionburn_bps: u16— default 1000 (10%)staker_share_bps: u16— default 5000 (50%)grant_share_bps: u16— default 2000 (20%)treasury_share_bps: u16— default 2000 (20%)burn_cap_bps: u16— 2000 (hard ceiling; meta-governance only)staker_cap_bps: u16— 7500 (hard ceiling; meta-governance only)grant_cap_bps: u16— 3000 (hard ceiling; meta-governance only)treasury_cap_bps: u16— 3000 (hard ceiling; meta-governance only)epoch_duration_secs: i64— default7 * 86400(1 week); tunable by governancenext_epoch_id: u64— monotonic epoch counter; bumped byprocess_epochclaim_window_secs: i64— default90 * 86400(90 days) — unclaimed staker-share rolls into the next epoch's staker pool viasweep_stale_epochmin_epoch_total_for_burn: u64— default10_000 * 10^decimals; epochs below threshold skipexecute_burnand roll the bucket (burns are irreversible; below-threshold burns waste CU without moving the inflation needle)grant_recipient: Pubkey— grant-pool ATA receiver (governance-set; default = meta_authority-owned ATA); outflow from this ATA is outside this programtreasury_recipient: Pubkey— protocol-treasury ATA receiver (governance-set; default = governance_program-owned ATA)paused: bool— blocksprocess_epoch/claim_staker/execute_burn/collect_fees; slash-intake stays open (cannot trap value during a pause); hook allowlist ops continuebump: u8
EpochAccount PDA — per epoch
Seeds:
[b"epoch", epoch_id.to_le_bytes()]Fields:
epoch_id: u64started_at_slot: u64started_at_ts: i64closed_at_slot: Option<u64>closed_at_ts: Option<i64>snapshot_id: u64— NXSStaking snapshot captured atprocess_epochclose (for staker-share weight)total_collected: u64— sum of fees harvested + slashes received + collateral forfeited into this epoch's pre-split poolburn_amount: u64— computed atprocess_epoch; committed onexecute_burnburn_executed: boolstaker_amount: u64— committed to epoch's staker pool atprocess_epochstaker_distribution_root: [u8; 32]— committed viacommit_distribution_rootstaker_distribution_committed: boolstaker_claimed_total: u64— sum of claimed principal across this epoch'sclaim_stakercalls; bounded bystaker_amountgrant_amount: u64— transferred onprocess_epochtogrant_recipienttreasury_amount: u64— transferred onprocess_epochtotreasury_recipientstale_swept: bool— set whensweep_stale_epochrolls residuals forwardbump: u8
The 4
*_amountfields MUST sum tototal_collected— no dust, no rounding drift. Dust from bps split (at most 3 units per epoch across 4 buckets) lands intreasury_amountper the invariant.
StakerClaim PDA — per (epoch_id, staker)
Seeds:
[b"claim", epoch_id.to_le_bytes(), staker.as_ref()]Fields:
epoch_id: u64staker: Pubkeyamount_claimed: u64— equals the merkle-leaf amount on successful claimclaimed_at_slot: u64bump: u8
Existence of this PDA prevents double-claim (Anchor
initat claim-time fails on replay). Rent reclaimed onsweep_stale_epochpost-claim-window.
IntakeVault — Token-2022 ATA owned by [b"intake_vault"] PDA
- Seeds:
[b"intake_vault"](the PDA; the ATA is then its associated token account forsaep_mint) - Purpose: single inbound sink for harvested TransferFees + slashes + proposer-collateral forfeits.
process_epochsweeps this vault's balance into the 4 outbound vaults in a single atomic instruction. Slashers and forfeiterstransfer_checkeddirectly into this ATA.
BurnVault — Token-2022 ATA owned by [b"burn_vault"] PDA
- Seeds:
[b"burn_vault"] - Purpose: staging ATA for the burn bucket.
process_epochmovesburn_amounthere;execute_burnCPIsburn_checkedsigned by the PermanentDelegate PDA against this ATA.
StakerVault — Token-2022 ATA owned by [b"staker_vault"] PDA
- Seeds:
[b"staker_vault"] - Purpose: staging ATA for the staker-share bucket.
claim_stakertransfers from this ATA to the staker's ATA, signed by the PDA.
ReentrancyGuard (program-global, scaffolded)
- Standard pattern from
programs/agent_registry/src/guard.rs. Inbound-CPI guard onrecord_slash_receipt+record_collateral_forfeit(callers must be the registered AgentRegistry / DisputeArbitration / GovernanceProgram / NXSStaking; caller-side guard must be active; FeeCollector's guard must be inactive pre-entry). Outbound-CPI guard onprocess_epoch(the big fan-out: 3 ×transfer_checked+ 1 ×transferto staker vault) andexecute_burn(CPI to Token-2022burn_checked).
HookAllowlist / AgentHookAllowlist / call-site ids — existing scaffold
- Scaffolded per F-2026-05 + F-2026-06 (see
programs/fee_collector/src/{state,hook,instructions/*}.rs). Not re-described here; the spec ratifies the existing shape: 16-pubkey global list + 4-pubkey per-agent override + 12 call-site ids on the TaskMarket / TreasuryStandard side,assert_hook_allowed_at_siteemitsHookRejectedwith the site id so the indexer can bucket rejections. This subsystem is orthogonal to the distribution pipeline — kept under the same program because both share the "inspects the SAEP mint extensions at runtime" semantics and the TransferHook callback lives here. Migration to a dedicatedhook_guardprogram is a post-audit decision (Open Question).
Enums
enum EpochStatus {
Open, // intake vault accrues; process_epoch not yet called
Splitting, // process_epoch committed bucket amounts; grant + treasury moved; burn + staker vaults hold residual
DistributionCommitted, // staker root committed; claim_staker open
Stale, // claim window elapsed; residuals swept to next epoch's intake
}
Open → Splitting via process_epoch. Splitting → DistributionCommitted via commit_distribution_root (only applies to the staker bucket — grant + treasury + burn all completed at process_epoch close, modulo execute_burn which is a separate on-chain irreversibility gate). DistributionCommitted → Stale via sweep_stale_epoch once claim_window_secs elapses.
State machine
collect_fees (permissionless crank, harvests TransferFee → intake_vault)
record_slash_receipt (CPI, transfer_checked → intake_vault) accrue
record_collateral_forfeit (CPI, transfer_checked → intake_vault) |
v
[Open]
|
process_epoch
|
v
[Splitting]
|
+----------------+---------------+
| |
commit_distribution_root execute_burn
| |
v v
[DistributionCommitted] (bucket closed)
|
claim_staker × N
|
(claim_window_secs elapses)
|
sweep_stale_epoch
|
v
[Stale]
process_epoch must precede both commit_distribution_root and execute_burn. The two follow-ups are independent — burn can happen before or after the distribution root is committed. claim_staker requires DistributionCommitted. sweep_stale_epoch rolls the unclaimed staker residual plus any un-burned burn_amount (if execute_burn was never called — below min_epoch_total_for_burn or operator neglect) into EpochAccount { epoch_id + 1 }.intake_vault as a bookkeeping top-up.
Instructions
init_config(authority, meta_authority, governance_program, nxs_staking, agent_registry, dispute_arbitration, emergency_council, saep_mint, grant_recipient, treasury_recipient, params) — one-shot, deployer
- Validation: singleton — fails if
FeeCollectorConfigexists.paramsmust satisfyburn_bps + staker_share_bps + grant_share_bps + treasury_share_bps == 10_000and each ≤ its respective cap. Mint hard-pin: fails ifsaep_mint's mint authority is notNone(M3 handover must be complete — or on devnet, matches the placeholder mint authority). - Effect: initializes
FeeCollectorConfig. Createstransfer_fee_authority,permanent_delegate,hook_authority,intake_vault,burn_vault,staker_vaultPDAs + their ATAs forsaep_mint. CreatesEpochAccount { epoch_id: 0, status: Open }. Does NOT write the mint extensions (those are set at mint-init time by the M3 bootstrap script; init_config verifies the PDA identities match). - Emits:
FeeCollectorInitialized - CU target: 80k
collect_fees(mint_holders: Vec<Pubkey>) — permissionless crank, up to 10 holders per call
- Validation:
!config.paused.mint_holders.len() <= 10(CU bound). Each holder is a Token-2022 account withsaep_mintand non-zerowithheld_amount. - Effect: CPIs Token-2022
harvest_withheld_tokens_to_mintagainst the holder ATAs, thenwithdraw_withheld_tokens_from_mintfrom the mint tointake_vault. Signed bytransfer_fee_authorityPDA. Incrementscurrent_epoch.total_collectedby the harvested amount. - Emits:
FeesCollected { epoch_id, amount, collector } - CU target: 40k + 10k × len(mint_holders)
record_slash_receipt(amount) — CPI-only
- Signers: caller program (one of
nxs_staking,agent_registry,dispute_arbitration; identity hard-pinned fromFeeCollectorConfig) - Validation:
ReentrancyGuard.check_callee_preconditions: caller's guard active, FeeCollector's guard inactive pre-entry. Caller has already movedamountintointake_vaultviatransfer_checked— this ix is the accounting-only receipt; token movement pre-ix is validated by balance-delta ofintake_vault. - Effect: increments
current_epoch.total_collectedbyamount. Emits a typed receipt so the indexer can classify this inflow as a slash vs regular fee (slashes and fees are fungible post-split but the indexer surfaces the provenance on the portal). - Emits:
SlashReceived { epoch_id, slasher_program, amount }
record_collateral_forfeit(amount) — CPI-only
- Signers:
governance_program(proposer-collateral path) ORagent_registry(deposit-forfeit path) - Validation / Effect / Emits: same shape as
record_slash_receipt, different event type. - Emits:
CollateralForfeited { epoch_id, source_program, amount }
process_epoch() — permissionless crank
- Validation:
!config.paused.EpochAccount { epoch_id: current }.status == Open.now_ts >= current_epoch.started_at_ts + config.epoch_duration_secs.intake_vault.amount == current_epoch.total_collected(accounting parity check; prevents silent drift).
- Effect (state-before-CPI per §5.1):
- Sets
status = Splitting,closed_at_slot,closed_at_ts. - Computes bucket amounts from
total_collectedand the bps quadruple. Dust (total_collected - sum(buckets)∈{0..3}) lands intreasury_amount. - Writes
burn_amount/staker_amount/grant_amount/treasury_amountto theEpochAccount. - Captures
snapshot_id = nxs_staking.latest_committed_snapshot(read via CPI view — the snapshot_id is a stable referent, freshness is enforced atclaim_stakertime). - CPIs:
transfer_checkedintake → burn_vault (burn_amount), intake → staker_vault (staker_amount), intake →grant_recipient(grant_amount), intake →treasury_recipient(treasury_amount). 4 CPIs in deterministic order; balances-before/after assertion post-CPI. - Initializes
EpochAccount { epoch_id: current + 1, status: Open }. - Increments
config.next_epoch_id.
- Sets
- Emits:
EpochProcessed { epoch_id, total_collected, burn_amount, staker_amount, grant_amount, treasury_amount, snapshot_id } - CU target: 80k
commit_distribution_root(epoch_id, root, leaf_count, total_weight) — permissionless cranker
- Validation:
epoch.status == Splitting.leaf_count * average_claim ≈ staker_amount— this is NOT on-chain; verified off-chain. The on-chain check istotal_weight == leaf_sum(amount)— anyone can compute and commit; the first successful commit wins; subsequent commits fail becausestaker_distribution_committed == true.now_ts < epoch.closed_at_ts + 2 * 86400(2-day window; after this the epoch rolls as Stale and the staker bucket goes into the next epoch — preserves liveness if the cranker is unavailable).
- Effect: writes
staker_distribution_root = root,staker_distribution_committed = true,status = DistributionCommitted. - Emits:
DistributionRootCommitted { epoch_id, root, leaf_count, total_weight, committer } - CU target: 25k
claim_staker(epoch_id, amount, merkle_proof) — staker-signed
- Signers:
staker - Validation:
epoch.status == DistributionCommitted.now_ts < epoch.closed_at_ts + config.claim_window_secs.- Merkle proof of
(staker, amount)againstepoch.staker_distribution_root. StakerClaim { epoch_id, staker }PDA does not exist (first claim wins; Anchorinitenforces).epoch.staker_claimed_total + amount <= epoch.staker_amount(prevents over-withdrawal against a malformed root).
- Effect: initializes
StakerClaim. Incrementsepoch.staker_claimed_total. CPIstransfer_checkedfromstaker_vaultto staker's ATA. - Emits:
StakerClaimed { epoch_id, staker, amount } - CU target: 20k + 1k × proof_depth (max 24)
execute_burn(epoch_id) — permissionless crank
- Validation:
epoch.status ∈ {Splitting, DistributionCommitted, Stale}.!epoch.burn_executed.epoch.total_collected >= config.min_epoch_total_for_burn(below-threshold burns skipped; burn_amount rolls via sweep).
- Effect: ReentrancyGuard outbound-CPI. Sets
epoch.burn_executed = true(state-before-CPI). CPIs Token-2022burn_checkedsigned bypermanent_delegatePDA againstburn_vaultforepoch.burn_amount. Asserts post-CPI thatburn_vault.amount == prior - burn_amount. - Emits:
BurnExecuted { epoch_id, amount, crank } - CU target: 30k
sweep_stale_epoch(epoch_id) — permissionless crank
- Validation:
epoch.status ∈ {Splitting, DistributionCommitted}.now_ts >= epoch.closed_at_ts + config.claim_window_secs + 7 * 86400(7-day grace after the claim window closes).!epoch.stale_swept.
- Effect: computes
residual_staker = staker_amount - staker_claimed_total,residual_burn = burn_executed ? 0 : burn_amount. CPIstransfer_checkedfromstaker_vault+ (conditionally)burn_vaulttointake_vault. CreditsEpochAccount { epoch_id: epoch_id + 1 }.total_collectedby the swept amount. Setsstatus = Stale,stale_swept = true. - Emits:
EpochSwept { epoch_id, residual_staker, residual_burn, rolled_to_epoch } - CU target: 40k
gc_staker_claims(epoch_id, claim_accounts: Vec<Pubkey>) — permissionless crank
- Validation:
epoch.status == Stale.now_ts >= epoch.closed_at_ts + config.claim_window_secs + 30 * 86400.claim_accounts.len() <= 10. - Effect: closes up to 10
StakerClaimPDAs per call; rent reclaimed to the caller. Pure cleanup; no token movement.
set_distribution_params(burn_bps, staker_share_bps, grant_share_bps, treasury_share_bps)
- Signers:
meta_authorityCPI (Squads 6-of-9 via GovernanceProgramexecute_proposalwhen the proposal category isParameterChangetargeting FeeCollector AND the subkind is distribution-split) - Validation: sum == 10_000. Each ≤ its cap (
burn_cap_bps/staker_cap_bps/grant_cap_bps/treasury_cap_bps). Reentrancy-guard active for inbound CPI. - Effect: writes the new quadruple. Effective on the next epoch (current epoch's split already committed at
process_epoch, so a change mid-epoch cannot retroactively redistribute). - Emits:
DistributionParamsUpdated - CU target: 20k
set_params(params)
- Signers:
governance_programCPI (ParameterChange, subkind non-distribution —epoch_duration_secs,claim_window_secs,min_epoch_total_for_burn,grant_recipient,treasury_recipient) - Validation: caller = governance_program. Per-field bounds:
epoch_duration_secs ∈ [86400, 30 * 86400],claim_window_secs ∈ [7 * 86400, 365 * 86400],min_epoch_total_for_burnnon-zero. Recipient ATAs validated againstsaep_mint. - Emits:
ParamsUpdated
set_bucket_caps(burn_cap_bps, staker_cap_bps, grant_cap_bps, treasury_cap_bps)
- Signers:
meta_authorityCPI only (Squads 6-of-9 + GovernanceProgram meta-governance 21d timelock) - Validation: each cap ∈
[current_bps, 10_000]— caps can only widen, never narrow (narrowing below an active bps is a cross-config invariant violation); overall sum of caps ≤40_000(any allocation remains valid under the caps); reentrancy-guard active. - Emits:
BucketCapsUpdated
set_paused(paused: bool)
- Signers:
authorityORemergency_council - Effect: flips
config.paused. Blockscollect_fees/process_epoch/commit_distribution_root/claim_staker/execute_burn/sweep_stale_epoch. Slash-intake (record_slash_receipt) / forfeit (record_collateral_forfeit) / hook allowlist ops continue (cannot trap value during a pause, and the mint-level Pausable is orthogonal per §1.3). - Emits:
PausedSet
transfer_authority_two_step(new_authority) / accept_authority()
- Standard two-step authority handover for
FeeCollectorConfig.authority(Squads multisig migration). Mirrors the pattern used inagent_registry/nxs_staking.
TransferHook callback — execute(amount)
- Signers: the Token-2022 program itself (as part of transfer-hook dispatch);
saep_mintas the mint-identity anchor. - Validation:
mint == config.saep_mint.source_token_account.owneranddestination_token_account.ownerare both legitimate Token-2022 account types (not program-owned temporary accounts that could break post-transfer invariants).amount > 0.!config.paused— a Pausable-paused mint would reject at the Token-2022 layer first; this is belt-and-braces. - Effect: no token movement, no state mutation. Advisory-only at M2 per the spec — the hook is a structural gate. Rate-limit / compliance / agent-scoped checks are flagged as M4+ post-audit (Open Question).
- Emits: none (hook instructions that emit events break some wallet clients that inspect the transfer's log output; kept silent).
Hook allowlist + agent-hook allowlist + guard — existing scaffold
init_hook_allowlist,update_hook_allowlist,set_default_deny,transfer_hook_authority,accept_hook_authority,init_agent_hook_allowlist,update_agent_hook_allowlist,init_guard,set_allowed_callers,propose_guard_reset,admin_reset_guard— seeprograms/fee_collector/src/lib.rs. Spec ratifies the existing shape per F-2026-05 / F-2026-06. Future M4+ hook-logic extensions live here.
Events
Emitted at M1 (per programs/fee_collector/src/events.rs + emit! call sites): FeeCollectorInitialized, SlashReceived, CollateralForfeited, EpochProcessed, DistributionRootCommitted, StakerClaimed, BurnExecuted, EpochSwept, DistributionParamsUpdated, PausedSet, HookRejected, HookAllowlistInitialized, HookAllowlistUpdated, AgentHookAllowlistUpdated. (Forward-looking names ParamsUpdated / BucketCapsUpdated / AuthorityTransferProposed / AuthorityAccepted / GuardCallersUpdated / GuardResetProposed / GuardResetExecuted are reserved for M2 event-surface extensions tied to set_bucket_caps + transfer_authority_two_step + guard-callers admin ixs not yet scaffolded against dedicated event types — set_params currently emits DistributionParamsUpdated + PausedSet only.) Struct-defined but not yet emit!'d: FeesCollected / MintAccepted / GuardEntered / ReentrancyRejected — scaffold parity with other programs; wire-up lands when the matching ix surfaces extend.
Each epoch-scoped event carries epoch_id + timestamp. SlashReceived carries slasher_program; CollateralForfeited carries source_program — both populated from FeeCollectorConfig-hardpinned caller identity, not from the Accounts struct signer. Only MintAccepted / GuardEntered / ReentrancyRejected carry a slot field in the event body; indexer materializations resolve slot from the containing transaction for all other events.
Errors
Unauthorized, Paused, InvalidBpsSum, BucketCapExceeded, CapCannotNarrow, EpochNotOpen, EpochNotElapsed, IntakeAccountingDrift, EpochAlreadyProcessed, DistributionAlreadyCommitted, DistributionWindowElapsed, MerkleProofInvalid, ClaimAlreadyExists, ClaimOverflow, ClaimWindowElapsed, BurnBelowThreshold, BurnAlreadyExecuted, SweepGraceNotElapsed, NotStale, InvalidEpochState, InvalidMint, MintAuthorityMustBeNone, InvalidPdaOwner, InvalidRecipientMint, CallerNotRegisteredSlasher, CallerNotGovernance, CallerNotMetaAuthority, ReentrancyDetected, CpiDepthExceeded, ArithmeticOverflow, HookNotAllowed, InvalidProgramId, HookAllowlistFull, MintParseFailed. (Reentrancy / caller / CPI / hook errors reuse existing scaffold enum where present.)
CU budget (§2.1 targets; reviewer may tighten)
| Instruction | Target |
|---|---|
init_config |
80k |
collect_fees |
40k + 10k × holders (max 10) |
record_slash_receipt |
15k |
record_collateral_forfeit |
15k |
process_epoch |
80k (4 CPIs dominated) |
commit_distribution_root |
25k |
claim_staker |
20k + 1k × proof_depth (max 24) |
execute_burn |
30k (CPI dominated) |
sweep_stale_epoch |
40k |
gc_staker_claims |
10k + 2k × accounts (max 10) |
set_distribution_params |
20k |
set_params |
15k |
set_bucket_caps |
15k |
set_paused |
10k |
TransferHook execute |
5k (advisory; no state change) |
process_epoch is the hot path and the most CU-dense ix in the program; reviewer may push for splitting into process_epoch_split (amounts only) + process_epoch_fan_out (the 4 CPIs) if CU proves tight against the 200k compute-unit budget defaults. Splitting is functionally safe because the intermediate state (Splitting with zero CPIs executed) is an invariant-stable checkpoint.
Invariants
burn_bps + staker_share_bps + grant_share_bps + treasury_share_bps == 10_000at every write. Enforced on everyset_distribution_params+init_config+ (on read) everyprocess_epoch.burn_bps <= burn_cap_bps; same for the 3 other buckets. Caps only widen; reverting below an active bps is rejected.burn_amount + staker_amount + grant_amount + treasury_amount == total_collectedper epoch. Dust ≤ 3 units/epoch lands intreasury_amount.intake_vault.amount == current_epoch.total_collectedbeforeprocess_epoch. Asserted pre-split.staker_claimed_total <= staker_amountper epoch. Enforced per claim.staker_claimed_total + sweep_residual_staker == staker_amountaftersweep_stale_epoch.burn_amountis only subtracted fromburn_vaultviaburn_checkedfrom PermanentDelegate PDA; no other code path moves tokens out ofburn_vault.- Burn is irreversible and accounted:
epoch.burn_executed == true ⇒ BurnExecuted event emitted with matching amount. No path double-burns the same epoch. process_epochinitializesEpochAccount { epoch_id + 1, status: Open }atomically with closing the current epoch; intake continues immediately into the new epoch.record_slash_receipt+record_collateral_forfeitcan only be CPI'd by registered slashers / governance / agent_registry; caller-program identity is hard-pinned fromFeeCollectorConfig(not from the Accounts struct signer pubkey).TransferHook::executenever fails except for structural reasons (wrong mint, zero amount, paused config). Rate-limit / compliance rejections are M4+.permanent_delegatePDA is the only signer that can invokeburn_checkedagainstburn_vault. Mint authority isNonepost-handover; no alternative burn path exists.set_bucket_capsnarrowing is rejected; caps only widen via meta-governance.
Security checks (backend §5.1)
- Account Validation: Anchor seeds + bumps on
FeeCollectorConfig,EpochAccount,StakerClaim, plus PDA-derived ATA owners forintake_vault/burn_vault/staker_vault. Discriminator enforced. CPI identities for GovernanceProgram / NXSStaking / AgentRegistry / DisputeArbitration / emergency_council all read fromFeeCollectorConfig— hard equality, never caller-supplied. Mint identity hard-pinned at init; everytransfer_checked+harvest_withheld_tokens_to_mintcall validates the mint matches. - Re-entrancy: inbound-CPI (
record_slash_receipt,record_collateral_forfeit,set_distribution_params,set_params,set_bucket_caps) goes throughcheck_callee_preconditions— caller's reentrancy flag must be active; FeeCollector's flag must be inactive pre-entry. Outbound-CPI (process_epochwith 4 fan-out transfers;execute_burnto the Token-2022 program;claim_stakertransfer) sets state before the CPI and flips the guard so a malicious downstream upgrade cannot re-enter and double-credit or double-burn.process_epoch's 4 CPIs go through a single guard scope — one entry/exit pair, not four — to amortize the CU cost. - Integer Safety:
u64for per-epoch amounts; the bps split computestotal * bps / 10_000in u128 then narrows to u64 (total_collectedmax is u64, u64 × u16 overflows u64 at ~2^48 — u128 intermediate is mandatory). Dust-sink logic useschecked_subon the remainder.staker_claimed_total + amount <= staker_amountviachecked_addagainst the bound.u128not needed fortotal_staked-style cross-account sums — FeeCollector's max per-epoch exposure is bounded by the live NXS supply, comfortably inside u64. - Authorization:
init_configdeployer-signed;collect_fees/process_epoch/commit_distribution_root/execute_burn/sweep_stale_epoch/gc_staker_claimspermissionless (status- or time-gated);claim_stakerstaker-signed (merkle proof);record_*CPI-only from registered callers;set_distribution_params+set_bucket_capsmeta-authority CPI;set_paramsgovernance CPI;set_pausedauthority OR emergency_council;transfer_authority_two_step/accept_authoritytwo-step. - Token-2022 Extension Safety:
transfer_checkedexclusively — no rawtransfer.burn_checkedfor burns.harvest_withheld_tokens_to_mint+withdraw_withheld_tokens_from_mintfor TransferFee intake, signed by the program-ownedtransfer_fee_authorityPDA.init_configasserts the mint's mint authority isNone(post-M3-handover invariant; pre-M3 on devnet the asserting path is toggled off via adev_mode_skip_mint_auth_checkbool — flagged in Open Questions). - Mint Extension Expectations:
inspect_mint_extensionsruns at init and at every distribution checkpoint to detect drift. Specifically: TransferHook program id isfee_collector, PermanentDelegate delegate ispermanent_delegatePDA, TransferFee authority istransfer_fee_authorityPDA. Drift between the on-chain mint extension state andFeeCollectorConfig's cached identities is rejected (hard fail) — prevents an attacker who somehow flipped the mint's hook to a sibling program from leveraging the config's cached pubkeys. Seehook.rs::inspect_mint_extensions. - Slashing / Burn Safety:
execute_burnis state-before-CPI + reentrancy-guarded; burn is irreversible. Below-threshold epochs skip burn and sweep the bucket forward (no tiny-burn CU waste; no burn-by-inattention if the crank sits unused).burn_cap_bpshard ceiling 2000 (20%) per §1.3 ratification. - Distribution Safety: merkle root is committed by a permissionless cranker; first-commit-wins.
commit_distribution_roothas a 2-day window after epoch close; past that, the staker bucket rolls to the next epoch's intake as Stale residual — preserves liveness against an absent cranker. Merkle proof depth bounded at 24 (matches NXSStaking snapshot depth).staker_claimed_total <= staker_amountenforced per claim prevents over-withdrawal against a malformed root; a dishonest cranker cannot drain the staker_vault beyondstaker_amounteven with a bad root. - Hook Safety: advisory TransferHook
execute— no state change, no token movement, no failure except structural. This is the Token-2022 contract ("hooks cannot fail except for structural reasons") at M2. M4+ hook logic lands as a separate ix family with its own invariants (Open Question). - Upgrade Safety: Squads 4-of-7, 7-day standard timelock per §2.6. Meta-governance (Squads 6-of-9, 21d) for distribution-bps + bucket-cap changes.
- Pause:
config.pausedblocks fee-flow and distribution paths; slash-intake + hook allowlist ops continue so inbound value is never trapped and the hook fence remains operational during a pause. Mint-level Pausable is orthogonal; pausing the mint stops all SAEP transfers including the fee harvest itself (since harvest is a Token-2022 ix) — both layers are wired so the protocol can recover from either. - Jito bundle assumption:
collect_fees+process_epochare individually atomic; no multi-tx bundle dependency. The M3 mint bootstrap ceremony bundlesinit_config+ mint extension writes + authority handover into one Jito bundle (seespecs/token2022-saep-mint.md) — this program's init_config runs atomically from the bundle's perspective. - DOS surface:
collect_feesbounded at 10 holders per call;gc_staker_claimsbounded at 10 accounts per call;process_epochhas a fixed 4-CPI fan-out.EpochAccountproliferation is unbounded over time but reclaimable:sweep_stale_epochsets the terminal status andgc_staker_claimsreclaims claim-account rent; a futuregc_stale_epoch(M3 ops) will reclaimEpochAccountrent itself once all downstream PDAs are gc'd.
CPI contract surface
FeeCollector exposes 5 CPI targets to other SAEP programs + 1 external interface:
record_slash_receipt(amount)— called byNXSStaking::execute_slash,AgentRegistry::execute_slash,DisputeArbitration::execute_slash. Caller must be registered. Token movement happens before the CPI (callertransfer_checkeds intointake_vault); this ix is the accounting receipt.record_collateral_forfeit(amount)— called byGovernanceProgram::finalize_proposal(failed-without-quorum) orAgentRegistry::forfeit_deposit(M3). Same pattern asrecord_slash_receipt.set_distribution_params(bps_quadruple)— called byGovernanceProgram::execute_proposalwhen category isParameterChange/ subkind=distribution. Caller = meta_authority (Squads 6-of-9).set_params(params)— called byGovernanceProgram::execute_proposalwhen category isParameterChange/ subkind=ops.set_bucket_caps(caps_quadruple)— called byGovernanceProgram::execute_proposalwhen category is meta-governance / subkind=bucket-caps.- TransferHook
execute— Token-2022 program CPIs here on every SAEP transfer. External interface per the TransferHookInterface spec; not a SAEP-program-only surface.
Each CPI site on the caller side does NOT mirror FeeCollector state into its own PDAs (single source of truth). The NXSStaking::execute_slash → transfer_checked + record_slash_receipt pair is specifically a 2-step sequence (not one CPI) to preserve the intake_vault.amount == sum(total_collected) invariant that process_epoch relies on.
Devnet bring-up notes (§4.3)
- Init runs the 48h
dev_mode_timelock_override_secsshadow per §4.3. The override only EXTENDS the natural timelock (max of computed + override). Cannot shorten. - Pre-M3,
saep_mintpoints at a placeholder Token-2022 mint created at devnet bootstrap. The placeholder has TransferFee + TransferHook + PermanentDelegate extensions pre-configured against FeeCollector's PDA identities (soinit_config's drift check passes), but the mint authority may be non-None (tester-owned). Adev_mode_skip_mint_auth_check: boolfield onFeeCollectorConfiggates theMintAuthorityMustBeNoneassertion — toggled off on mainnet via meta-governance before the M3 real mint lands. (Open Question on whether the toggle is strictly necessary vs. spec'ing a distinct devnet-only mint-init path.) - A devnet-only
force_close_epochix is intentionally NOT included. Devnet epoch durations are real; bankrun warps the clock instead. - Hook allowlist ops are devnet-open: the default allowlist seeds with no entries +
default_deny=falseso unknown hooks warn but don't block. M3 flipsdefault_deny=trueand seeds the allowlist with the known-good NXSStaking / AgentRegistry / etc. hooks (there are none at M2; TaskMarket'stransfer_checkedto the SAEP mint invokes FeeCollector's own hook, not a third-party one).
Open questions for reviewer
- 4-bucket split ratios. Defaults
1000 / 5000 / 2000 / 2000bps sum to 10_000. Backend PDF §1.3 cites the first three (burn_bps=1000,staker_share=5000,grant_bps=2000); the remaining 2000 is the protocol-treasury bucket introduced in this spec. Reviewer may want (a) 3-bucket split with the 2000 bps folded into grant (7000 + stakers + burn), (b) the spec's 4-bucket split where treasury is distinct and governance-controlled, (c) an Appfact-operating bucket separate from both grant and treasury. Default: spec's 4-bucket split (treasury distinct, governance-controlled); reviewer may collapse. - Staker-share distribution mechanism. Spec uses merkle-root claim (off-chain cranker commits root → stakers pull). Alternative: streaming distribution (per-staker
stream_credit+claim_stream) that accrues linearly across the epoch. Merkle is cheaper on-chain and matches the governance spec's snapshot pattern; streaming is more staker-friendly but adds O(stakers) on-chain write amplification. Default: merkle; reviewer may push streaming for M4+. - Staker-share vs NXSStaking InterestBearing overlap. NXSStaking's APY comes from the mint's InterestBearing extension (§1.3). The staker-share bucket here is a second, fee-funded yield stream. Reviewer may ask whether the two should be unified (fee revenue funds the InterestBearing accrual via meta-governance
set_apyadjustments rather than a separate claim flow). Trade-off: InterestBearing is always-accruing and has no per-claim UX; merkle-claim rewards only stakers who claim (so lazy stakers subsidize active ones, which might be a feature). Default: keep as separate streams; flag for reviewer. - Epoch duration 1 week. Shorter epochs = more frequent distributions but higher crank overhead; longer = less frequent but higher per-epoch amounts and bigger at-stake value if a cranker outage occurs. Reviewer may tighten to 3 days or widen to 14.
- Burn bucket cap 20% vs 10% default. Cap allows governance to 2x the burn bucket in a deflationary regime (economic tuning). Reviewer may push for a hard 10% permanent cap (burn is irreversible; conservative ceiling limits meta-governance blast radius).
min_epoch_total_for_burnthreshold. 10_000 tokens × 10^decimals is arbitrary. The point is avoiding micro-burns when a below-threshold epoch lands in an otherwise-busy week. Reviewer may prefer (a) always burn regardless of size, (b) size by % of supply rather than absolute, (c) no threshold + accept micro-burns as a Schelling point.- TransferHook advisory vs enforcing. M2 hook is structural-only (no rate-limit / compliance). The flexibility is there (mint-extension drift check already runs at every epoch boundary), but enforcing hooks gate every transfer and any bug cascades to every SAEP holder. Reviewer may defer to M4+ or push for a minimal denylist at M3 (e.g., OFAC-sanctioned addresses). Default: M2 advisory; M4+ enforcing.
dev_mode_skip_mint_auth_checkvs distinct init path. Spec adds a devnet-only bool to skip theMintAuthorityMustBeNoneassertion during bring-up. Alternative: ainit_config_devnetix that skips the assertion and a separateinit_config_mainnetthat asserts it, gated by deploy-time feature flags. Default: bool with meta-governance toggle-off gate; flag for reviewer.- Cranker incentives for
process_epoch/commit_distribution_root/execute_burn. Permissionless crank paths rely on someone calling. Options: (a) cranker gets 10 bps of the epoch as a reward, carved from the treasury bucket (introduces a 5th bucket); (b) Appfact operates the cranker as infrastructure and no incentive is needed; (c) cranker isauthorityonly (not permissionless). Default: (b) for M2, (a) for M4+ once operational data exists to size the incentive. - Separate grant vs treasury recipients.
grant_recipientandtreasury_recipientdefault to different Squads multisigs. Reviewer may simplify to a single multisig with governance-controlled outflow categorization (saves 2000 bps of indirection at the cost of losing the "grant spend is meta-gov-only" boundary). Default: separate. - Hook allowlist merger into a dedicated program. The F-2026-05 scaffold landed in FeeCollector because both share the mint-extension-inspection primitive. A post-audit cycle may split
hook_guardinto its own program for cleaner ownership (TaskMarket / TreasuryStandard CPI intohook_guard::assert_hook_allowed_at_siteinstead of FeeCollector's). Default: keep merged for M2; split post-audit if OtterSec flags the coupling.
Done-checklist
- Full state machine implemented; illegal transitions rejected
-
init_configrejects non-summing bps, non-None mint authority (gated ondev_mode_skip_mint_auth_check), mismatched mint-extension identities -
collect_feesharvests up to 10 holders atomically;transfer_fee_authorityPDA signs; intake accounting tight -
process_epochrejects pre-duration; splits with dust-to-treasury; 4 fan-out CPIs in deterministic order; next epoch opened atomically -
commit_distribution_rootfirst-write-wins; 2-day window enforced; leaf-sum invariant checked -
claim_stakerrejects expired window / missing proof / double-claim / over-withdrawal; PDA seed correct -
execute_burnrejects below-threshold + already-executed; state-before-CPI; post-CPI balance assertion -
sweep_stale_epochrolls residuals to next epoch; 7d grace after claim window -
gc_staker_claimscleans up claim PDAs post-grace -
set_distribution_paramsrejects non-summing / over-cap quadruple; meta-authority CPI only -
set_paramsper-field bounds enforced; governance-CPI only -
set_bucket_capsrejects narrowing; meta-authority CPI only -
set_pausedblocks fee-flow paths; leaves slash-intake + hook ops open - TransferHook
executerejects wrong mint / zero amount / paused; no state change, no token movement - Reentrancy test: malicious slasher upgrade attempts re-entry on
record_slash_receipt— rejected - Reentrancy test: malicious Token-2022-program replacement attempts re-entry during
process_epochCPI fan-out — rejected (state-before-CPI defense) - Token-2022 test: mint-extension drift (third party flips TransferHook program id) —
inspect_mint_extensionsdetects and rejects at next checkpoint - Token-2022 test:
burn_checkedfrompermanent_delegatePDA succeeds; mint-authority-signed burn fails (authority is None) - Bankrun test: 7d epoch duration — process before 7d (rejected), warp to 7d (succeeds)
- Bankrun test: 90d claim window — claim within window (succeeds), after window (rejected)
- Bankrun test: 2d distribution-root commit window — commit within (succeeds), after (rejected; staker bucket rolls via sweep)
- Bankrun test: 30d
gc_staker_claimsgrace — gc within (rejected), after (succeeds) - Fuzz test: bps quadruple over
set_distribution_params— every non-summing / over-cap input rejected - Fuzz test: merkle proof depth 1..24, valid proofs accepted, off-by-one invalid proofs rejected
- Golden-path integration test (localnet): 10 fees collected → process_epoch → 3 stakers claim from 10-leaf merkle tree → burn executed → sweep 3 residual claims forward
- Slash-integration test: NXSStaking::execute_slash → transfer_checked → record_slash_receipt → epoch shows correct total_collected + provenance tag
- Proposer-collateral integration test: GovernanceProgram proposal fails without quorum → record_collateral_forfeit → epoch total up
- CU measurements per instruction in
reports/fee-collector-anchor.md - IDL at
target/idl/fee_collector.json - Security auditor pass (§5.1); findings closed
- Reviewer gate green; spec ready for Neodyme M2 queue