Skip to content

feat(solana): add vesting program with cliff and linear unlock - #35

Merged
PetarStoev02 merged 5 commits into
w3-kit:mainfrom
0xParsaYama:feat/solana-vesting
Jun 26, 2026
Merged

PetarStoev02 merged 5 commits into
w3-kit:mainfrom
0xParsaYama:feat/solana-vesting

Conversation

@0xParsaYama

@0xParsaYama 0xParsaYama commented Jun 23, 2026 •

Copy link
Copy Markdown
Contributor
  • Anchor-based SPL token vesting with configurable cliff and duration
  • Linear unlock after cliff period
  • Optional revocability by authority
  • Supports both Token Program and Token-2022
  • Full test suite covering initialize, deposit, release, revoke

Closes #11

What

Why

How

Checklist

  • Tests pass locally
  • No unrelated changes included
  • Documentation updated (if applicable)

- Anchor-based SPL token vesting with configurable cliff and duration
- Linear unlock after cliff period
- Optional revocability by authority
- Supports both Token Program and Token-2022
- Full test suite covering initialize, deposit, release, revoke

Closes w3-kit#11

@PetarStoev02 PetarStoev02 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the contribution. Math and test coverage are solid; a few blockers and a handful of cleanups before this is ready to merge.

Blockers

  1. Missing .learn.md. Issue #11 explicitly requires it, and the sibling solana/staking/ program ships one (deep walkthrough plus security considerations). Please add solana/vesting/.learn.md.
  2. release does not check revoked. Defensive check missing; see inline comment.
  3. README path mismatch (programs/vesting-template/src/ vs actual programs/vesting/).
  4. Cargo.lock not committed. Sibling solana/staking/ ships its lockfile; please commit yours for reproducible builds.

Repo convention drift

  • File naming: states.rs / errors.rs (plural) here vs state.rs / error.rs (singular) in solana/staking/. Pick one and align across the repo.
  • Anchor version: 1.0.2 here vs 1.0.1 in staking. See inline comment.
  • PR description has the unfilled ## What / ## Why / ## How template sections. Please fill them in or remove.

Nice-to-have

  • Document the one-schedule-per-(authority, beneficiary, mint) PDA constraint (see inline comment on initialize.rs).
  • Document the retroactive deposit semantics in the README. Tokens deposited mid-schedule become proportionally vested immediately. This matches OZ behavior but is a UX footgun worth calling out.
  • Tests use wall-clock time and tolerance windows; consider solana-bankrun with setClock for deterministic timing.
  • README architecture diagram has misaligned box characters when rendered in monospace.

Comment thread solana/vesting/README.md Resolved
Comment thread solana/vesting/tests/3_release.ts Resolved
Parsa Rahbaran and others added 2 commits June 24, 2026 18:12
- Add revoked guard to release handler (security fix)
- Make release permissionless (OZ VestingWallet semantics)
- Add schedule_id seed to PDA for multiple concurrent grants
- Migrate tests to solana-bankrun with deterministic setClock
- Add .learn.md with deep walkthrough and security considerations
- Document retroactive deposit semantics in README
- Fix README path and file naming references
- Commit Cargo.lock for reproducible builds
fix(vesting): address code review blockers and improvements

- Add revoked guard to release handler (security fix)
- Make release permissionless (OZ VestingWallet semantics)
- Add schedule_id seed to PDA for multiple concurrent grants
- Migrate tests to solana-bankrun with deterministic setClock
- Add .learn.md with deep walkthrough and security considerations
- Document retroactive deposit semantics in README
- Fix README path and file naming references
- Commit Cargo.lock for reproducible builds
@0xParsaYama

Copy link
Copy Markdown
Contributor Author

Hi,

Thanks for the thorough code review! I have addressed all the blockers and implemented the suggested improvements in my latest commit. Here is a summary of the changes:

🔒 Blockers & Security Fixes

  • Added Revoked Guard: Included a defensive check (!vesting_schedule.revoked) at the top of the release handler to ensure no funds can be drained after a schedule is revoked.
  • Permissionless Release: Dropped the strict beneficiary Signer requirement to match OpenZeppelin's VestingWallet permissionless semantics.
  • PDA Seed Collision Fix: Added a schedule_id nonce to the PDA derivation to support multiple concurrent grants for the same authority/beneficiary/mint triple.

⚙️ Repo Conventions & Cleanups

  • Testing Migration: Successfully migrated the TS test suite to solana-bankrun. Used setClock for precise, deterministic time travel instead of flaky wall-clock assertions.
  • Documentation: Created the requested solana/vesting/.learn.md deep walkthrough and added notes regarding the retroactive deposit UX semantics in the README.
  • Consistency: Aligned the directory path references in the README and fixed file naming conventions to match the singular pattern (state.rs / error.rs). Committed Cargo.lock for reproducible builds.

The PR is now ready for another look! Let me know if everything looks good to merge. 🚀

@PetarStoev02 PetarStoev02 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the quick turnaround. All four round-1 blockers are addressed, the .learn.md is genuinely good, and the bankrun migration with exact assertions is the right call.

However, the permissionless release change introduced a high-severity vulnerability that I'd block on before merging. Details in the inline comment on release.rs, but the short version: there is no constraint that beneficiary_token_account.owner == vesting_schedule.beneficiary, so any caller can redirect vested tokens to a token account they control as long as the mint matches.

The .learn.md "Permissionless Release Safety" section currently asserts the opposite invariant, so please update it alongside the fix and add a negative test (permissionless release with an attacker-owned destination should fail with a constraint violation).

Other items

  • Orphan files: programs/vesting/src/states.rs and errors.rs are still in the tree but no longer declared in lib.rs. They're effectively dead code; please delete them.
  • npm test no longer triggers anchor build. Bankrun loads target/deploy/vesting.so but the test script doesn't produce it. First-time contributors will hit a confusing failure. Suggest adding a pretest: "anchor build" script.
  • Anchor.toml has an empty trailing [hooks] section that looks accidental.

Minor

  • The "third-party crank" test uses ctx.authority as the cranker. A fresh random keypair would more rigorously prove permissionless. With the fix above in place, also add a malicious-destination test that asserts the constraint kicks in.

/// Beneficiary's token account to receive released tokens
#[account(
mut,
constraint = beneficiary_token_account.mint == vesting_schedule.mint

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High severity: missing owner constraint allows token theft via permissionless release.

This constraint only checks the mint, not the owner. Combined with the new permissionless release(), this is exploitable.

Walkthrough:

  1. release is now permissionless; payer is any signer.
  2. has_one = beneficiary on vesting_schedule validates that the beneficiary pubkey passed equals vesting_schedule.beneficiary. It does not constrain beneficiary_token_account.
  3. The only check on beneficiary_token_account is mint == vesting_schedule.mint.
  4. So an attacker can call release with the legitimate beneficiary pubkey (to satisfy has_one) and a token account they themselves own that holds the same mint. The PDA-signed transfer_checked will then send vested tokens to the attacker's account.

Before the permissionless change this was safe because the beneficiary had to sign, so no one else could craft the transaction. After the change, nothing ties the destination back to the beneficiary.

The .learn.md "Permissionless Release Safety" section currently asserts the opposite invariant; update that section alongside the fix.

Suggested fix (simplest):

#[account(
    mut,
    constraint = beneficiary_token_account.mint == vesting_schedule.mint,
    constraint = beneficiary_token_account.owner == vesting_schedule.beneficiary,
)]
pub beneficiary_token_account: InterfaceAccount<'info, TokenAccount>,

Or pin to the ATA explicitly if that's the intended UX:

#[account(
    mut,
    associated_token::mint = mint,
    associated_token::authority = beneficiary,
)]
pub beneficiary_token_account: InterfaceAccount<'info, TokenAccount>,

Please also add a negative test: permissionless release with an attacker-owned destination token account should fail with a constraint violation.

/// Beneficiary's token account to receive any already-vested tokens
#[account(
mut,
constraint = beneficiary_token_account.mint == vesting_schedule.mint

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same gap as release.rs: beneficiary_token_account has no owner constraint, and authority_token_account (line 117) is in the same state. Lower severity here because authority must sign revoke, but worth tightening both for defense-in-depth and so the constraint pattern stays consistent across handlers.

constraint = beneficiary_token_account.mint == vesting_schedule.mint,
constraint = beneficiary_token_account.owner == vesting_schedule.beneficiary,

And analogously for authority_token_account with owner == vesting_schedule.authority.

pub mod error;
pub mod events;
pub mod instructions;
pub mod state;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Module declarations were renamed from errors / states to error / state, but the original programs/vesting/src/errors.rs and states.rs files weren't deleted. They're no longer part of the build (not declared as mods) but linger as orphan files. Please remove them so the tree is clean.

{
"name": "vesting-solana",
"scripts": {
"test": "RUST_LOG= npx ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous anchor test --validator legacy implicitly built the program. The new npm test runs ts-mocha directly, but bankrun loads target/deploy/vesting.so which npm test does not produce. A first-time contributor running npm install && npm test will hit a confusing "program not found" error.

Suggest:

"scripts": {
  "pretest": "anchor build",
  "test": "RUST_LOG= npx ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts"
}

Or at least add a note to the README under ## Test.

Comment thread solana/vesting/Anchor.toml Outdated
[scripts]
test = "npx ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts"

[hooks]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty [hooks] section at the bottom of the file looks accidental. If you don't have any hooks configured, please remove the header to avoid noise (and potential anchor CLI warnings).

@0xParsaYama

0xParsaYama commented Jun 25, 2026 •

Copy link
Copy Markdown
Contributor Author

Hi @PetarStoev02,

Thanks for the sharp catch on the missing owner constraints! You are 100% right — making release() permissionless without binding the beneficiary_token_account.owner to the vesting_schedule.beneficiary opened a high-severity token redirection vector.

I've addressed this and completely squared away the remaining items:

  1. Security Fixes (Defense-in-Depth):
    • Added constraint = beneficiary_token_account.owner == vesting_schedule.beneficiary in release.rs.
    • Tightened both beneficiary_token_account and authority_token_account owner checks in revoke.rs as well.
  2. Negative Testing:
    • Added a malicious-destination test case (rejects release to attacker token account (owner mismatch)) utilizing an unlinked token account to ensure the runtime throws InvalidBeneficiaryOwner.
  3. Docs & Cleanup:
    • Updated the .learn.md "Permissionless Release Safety" section to accurately document the constraint-level invariants.
    • Deleted the orphan files (states.rs and errors.rs).
    • Added "pretest": "anchor build" to package.json to prevent local/CI environment bankrun binary loading issues.
    • Removed the accidental empty [hooks] section from Anchor.toml.

Ready for your final eyes :D

@PetarStoev02 PetarStoev02 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, approving.

Great work on this. The owner-constraint fix is clean, the negative test is exactly the right shape, and the updated .learn.md walkthrough is going to be a genuinely useful reference for anyone porting OZ-style vesting to Solana. Solid response to three rounds of review.

Merging shortly. I'll open a small follow-up PR to remove the orphan errors.rs / states.rs files (no functional impact, just cleanup).

Congrats on shipping!

@PetarStoev02
PetarStoev02 merged commit 668cd48 into w3-kit:main Jun 26, 2026
PetarStoev02 added a commit that referenced this pull request Jun 26, 2026
- Remove orphan `programs/vesting/src/errors.rs` and `states.rs` left over from the module renames in #35
- CI: run avm/surfpool installs from repo root to bypass the per-package rust-toolchain.toml pin (1.89), letting the dtolnay-installed stable build avm's cargo-platform@0.3.3 dep
- Add `solana/vesting/.npmrc` with `legacy-peer-deps=true` to resolve the anchor-bankrun@0.5.0 peer dep mismatch with @coral-xyz/anchor@^0.32.1
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.

Contracts: Vesting program (Solana / Anchor)

2 participants