Repository navigation
feat(solana): add vesting program with cliff and linear unlock - #35
Conversation
- 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
left a comment
There was a problem hiding this comment.
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
- Missing
.learn.md. Issue #11 explicitly requires it, and the siblingsolana/staking/program ships one (deep walkthrough plus security considerations). Please addsolana/vesting/.learn.md. releasedoes not checkrevoked. Defensive check missing; see inline comment.- README path mismatch (
programs/vesting-template/src/vs actualprograms/vesting/). Cargo.locknot committed. Siblingsolana/staking/ships its lockfile; please commit yours for reproducible builds.
Repo convention drift
- File naming:
states.rs/errors.rs(plural) here vsstate.rs/error.rs(singular) insolana/staking/. Pick one and align across the repo. - Anchor version:
1.0.2here vs1.0.1in staking. See inline comment. - PR description has the unfilled
## What / ## Why / ## Howtemplate 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-bankrunwithsetClockfor deterministic timing. - README architecture diagram has misaligned box characters when rendered in monospace.
- 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
|
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
⚙️ Repo Conventions & Cleanups
The PR is now ready for another look! Let me know if everything looks good to merge. 🚀 |
PetarStoev02
left a comment
There was a problem hiding this comment.
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.rsanderrors.rsare still in the tree but no longer declared inlib.rs. They're effectively dead code; please delete them. npm testno longer triggersanchor build. Bankrun loadstarget/deploy/vesting.sobut the test script doesn't produce it. First-time contributors will hit a confusing failure. Suggest adding apretest: "anchor build"script.Anchor.tomlhas an empty trailing[hooks]section that looks accidental.
Minor
- The "third-party crank" test uses
ctx.authorityas 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 |
There was a problem hiding this comment.
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:
releaseis now permissionless;payeris any signer.has_one = beneficiaryonvesting_schedulevalidates that thebeneficiarypubkey passed equalsvesting_schedule.beneficiary. It does not constrainbeneficiary_token_account.- The only check on
beneficiary_token_accountismint == vesting_schedule.mint. - So an attacker can call
releasewith the legitimate beneficiary pubkey (to satisfyhas_one) and a token account they themselves own that holds the same mint. The PDA-signedtransfer_checkedwill 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 |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
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.
| [scripts] | ||
| test = "npx ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts" | ||
|
|
||
| [hooks] |
There was a problem hiding this comment.
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).
…ts and update docs
|
Hi @PetarStoev02, Thanks for the sharp catch on the missing owner constraints! You are 100% right — making I've addressed this and completely squared away the remaining items:
Ready for your final eyes :D |
PetarStoev02
left a comment
There was a problem hiding this comment.
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!
- 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
Closes #11
What
Why
How
Checklist