Skip to content

fix(rewards): harden direct close lifecycle and revoke accounting#32

Merged
dev-jodee merged 7 commits into
mainfrom
fix/direct-tombstone-and-revoke-hardening
Apr 22, 2026
Merged

fix(rewards): harden direct close lifecycle and revoke accounting#32
dev-jodee merged 7 commits into
mainfrom
fix/direct-tombstone-and-revoke-hardening

Conversation

@dev-jodee
Copy link
Copy Markdown
Collaborator

Summary

  • add direct distribution tombstone state/PDA checks so closed distributions cannot be recreated at the same address
  • create the tombstone during CloseDirectDistribution and require tombstone + system_program accounts in direct create/close flows
  • allow CloseDirectRecipient to succeed after the parent distribution account is closed
  • update merkle revoke non-vested handling to persist MerkleClaim.claimed_amount correctly when a claim account exists

Test Plan

  • just build
  • cargo test -p tests-rewards-program test_create_direct_distribution -- --nocapture
  • cargo test -p tests-rewards-program test_close_direct_distribution -- --nocapture
  • cargo test -p tests-rewards-program test_close_direct_recipient -- --nocapture
  • cargo test -p tests-rewards-program test_revoke_merkle_claim -- --nocapture
  • cargo test -p rewards-program

Add direct-distribution tombstones so a closed direct distribution cannot be recreated at the same PDA, and wire close/create instruction accounts accordingly. Also allow closing direct recipient accounts after the parent direct distribution is closed and keep merkle claim claimed_amount in sync for non-vested revoke flows.
Update direct distribution create/close web instruction builders to derive and include the tombstone PDA required by the generated client types after the direct tombstone hardening change.
@dev-jodee dev-jodee requested a review from amilz April 13, 2026 16:49
dev-jodee and others added 5 commits April 20, 2026 10:39
…mbstone PDA

Replace the separate DirectDistributionTombstone PDA with an in-place
state flip on the distribution PDA itself. On close, the distribution's
discriminator byte is rewritten to DirectDistributionClosed (1-byte data
= bump), the account is resized from 130 bytes down to 3 bytes, and the
freed rent is refunded to the authority. On create, the first byte is
inspected — if it matches DirectDistributionClosed::DISCRIMINATOR, the
instruction returns DistributionPermanentlyClosed.

Benefits over the tombstone approach:
- No extra account passed in each create/close transaction
- No extra PDA seed derivation
- Simpler mental model — closed is just a state of the same PDA
- Smaller transaction size

close_direct_recipient detects closed distributions by checking the
discriminator byte instead of the account owner (the PDA now always
stays program-owned).

IDL: CloseDirectDistribution lost tombstone + system_program accounts
(8 total); CreateDirectDistribution lost tombstone account (11 total).

Net -157 lines of code. All 407 unit + 207 integration tests pass.
Replace inline discriminator-byte checks in create_distribution and
close_recipient processors with a single `DirectDistribution::is_closed`
method. The method takes an AccountView and program ID, returns true
iff the account is program-owned and its first byte matches the
DirectDistributionClosed discriminator.

Callers are clearer about intent ("is this distribution closed?") and
the byte-layout detail is encapsulated on the type that owns it.
…t helpers

Pull the state-flip logic out of the close_direct_distribution processor:

- Add DirectDistribution::close_in_place(account, rent_recipient) which
  overwrites the account header with DirectDistributionClosed's discriminator
  + version, resizes the account to the closed marker's length, and refunds
  freed rent to the recipient.

- Add refund_excess_rent(account, recipient, new_size) as a general-purpose
  utility in pda_utils.rs for any program-owned account that was resized
  down and needs its excess lamports returned.

Processor drops ~15 lines of state-flip + rent-refund bookkeeping to a
single call.
Merge of main into the PR branch left close_pda_account in scope even though
its call site was replaced by DirectDistribution::close_in_place. Treated as
hard error under -D unused-imports, failing all 5 Rust CI jobs.
@dev-jodee dev-jodee merged commit aa1cfd9 into main Apr 22, 2026
7 checks passed
@dev-jodee dev-jodee deleted the fix/direct-tombstone-and-revoke-hardening branch April 22, 2026 20:13
dev-jodee added a commit that referenced this pull request Apr 24, 2026
Add the OtterSec security assessment (2026-04-24) covering the
rewards program at commit d795849. All 8 findings were resolved
in PRs #32-#37 and marked as such in the report.

Track the audited-through commit and unaudited delta in
audits/AUDIT_STATUS.md. Drop the "not audited" notice from the
README and add a Security Audit section linking the report.

Co-authored-by: Jo D <dev-jodee@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant