Skip to content

Performance package withdrawal limits - #494

Merged
metapileks merged 27 commits into
developfrom
pileks/met-708-price-based-performance-package-vesting-and-distribution
Nov 3, 2026
Merged

metapileks merged 27 commits into
developfrom
pileks/met-708-price-based-performance-package-vesting-and-distribution

Conversation

@metapileks

@metapileks metapileks commented Sep 14, 2026 •

Copy link
Copy Markdown
Collaborator

Unlocking and withdrawing are now separate steps. complete_unlock only marks tranches as unlocked; the recipient pulls what is unlocked either as tokens (withdraw_tokens) or by selling into the DAO's spot pool and receiving the USDC (withdraw_via_sell).

A package may carry a withdrawal policy: a cap on tokens and a cap on quote value per fixed window, a mode saying which of the two routes are open, and an end date after which the caps fall away. Token withdrawals are valued at the higher of the pool's price observation and its reserve price; sells count what the pool actually paid. Limits are set at creation or through the existing two-party change flow, which gains an UnlockTerms change that moves the cliff and replaces the limits in one step. Without an active policy both routes are open and uncapped.

The performance package account grows from 520 to 582 bytes. A permissionless resize migrates live packages, and every instruction refuses an unmigrated package.

Instruction changes

Instruction Change
resize_performance_package New, permissionless and idempotent. Grows the account to 582 bytes with withdrawal_policy = None; the payer covers the extra rent.
all existing instructions Refused with AccountNotMigrated until the package is resized.
initialize_performance_package_with_limits New, same accounts as the plain initializer. Takes { base, limits } and stores a policy anchored at the creation clock. Caps must be non-zero, the end in the future and the window at least one second (InvalidWithdrawalLimits).
complete_unlock Never transfers tokens. Accounts shrink to the package and the oracle.
withdraw_tokens New, recipient-signed. Pays amount from the vault, bounded by vault minus still-locked (InsufficientWithdrawableBalance). Under an active policy the mode must allow tokens and the window's token and quote caps must fit, valued from the Dao at the oracle account.
withdraw_via_sell New, recipient-signed. The package sells amount from its vault into the DAO's spot pool through futarchy's spot_swap, guarded by min_quote_out, and forwards exactly the proceeds. Under an active policy the mode must allow sells, the token cap applies and the quote cap counts the proceeds.
propose_change / execute_change New ChangeType::UnlockTerms { min_unlock_timestamp, limits: Option<LimitsParams> }, allowed in any state. Propose validates the limits; execute sets the cliff and replaces the policy: same window length keeps the usage, a different one re-anchors the windows at now, None removes it.
burn_performance_package Pays the withdrawable balance to the recipient's ATA (created if missing, paid by the admin) before burning the locked remainder, then closes the emptied vault to the spill account. Optionally takes a quote mint, the package's ATA for it and a destination token account: the ATA's balance moves to the destination and the ATA closes. The quote mint must differ from the token mint (InvalidQuoteMint); the ATA and destination come together (QuoteSweepAccountsIncomplete). New accounts: recipient, its ATA, the three optional quote accounts, system and associated token programs.

State, events and errors

  • PerformancePackage.withdrawal_policy: Option<WithdrawalPolicy> appended, with limits: WithdrawalLimits (start, end, window seconds, both caps, mode Tokens | Sell | Both) and usage: WindowUsage (window index, tokens used, quote used). OldPerformancePackage decodes the 520-byte layout for the resize.
  • The program now depends on futarchy with the cpi feature.
  • New events TokensWithdrawn (with capped: Option<CappedWithdrawal>, the valuation price, quote value and usage when limits were active) and TokensSold (amount, proceeds, min_quote_out, capped: Option<WindowUsage>).
  • Errors appended: InvalidWithdrawalLimits, InsufficientWithdrawableBalance, TokenWindowLimitExceeded, QuoteWindowLimitExceeded, InvalidPriceObservation, WithdrawTokensDisabled, WithdrawViaSellDisabled, AccountNotMigrated, InvalidQuoteMint, QuoteSweepAccountsIncomplete.

SDK and scripts

  • price_based_performance_package/v0.6 regenerated in place. completeUnlockIx takes only the package and oracle; new initializePerformancePackageWithLimitsIx, withdrawTokensIx, withdrawViaSellIx, resizePerformancePackageIx; burnPerformancePackageIx takes the recipient and, optionally, quoteMint and quoteDestination for the sweep; proposeChangeIx is typed with ProposeChangeParams.
  • New helper subpath price_based_performance_package/v0.6/withdrawalLimits: active policy, current window and effective usage, withdrawable balance, valuation price, maximum token withdrawal and a sell proceeds estimate, all computed from fetched accounts.
  • Scripts: resizePerformancePackages.ts (modelled on resizeDaos.ts), a proposePerformancePackageUnlockTerms DAO action with the Rip Cars admin enqueue rip-cars/proposeUnlockTerms.ts, executeChange.ts for the recipient, and the burn script passing the new accounts.

RetriggerView in GreptileConfidence Score: 5/5

The changes since the previous review appear safe to merge.

Summary

The PR separates unlocking from withdrawals, adds withdrawal limits and sell withdrawals, and updates migration, retirement, and SDK support.

Since the previous review:

  • Recipients can choose a token payout or a sale into the DAO’s spot pool.
  • Older packages can be resized before they use the new withdrawal flows.
  • Burning a package pays its unlocked tokens to the recipient first.

Reviews (4) · Last reviewed commit: "fix(pbpp): build the v0.6 burn script wi..." · Reviewed by Greptile

@github-actions

github-actions Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Repository Guard

  • Cargo.lock: pass
  • yarn.lock (sdk): pass
  • yarn.lock (root): pass
  • Repo guard: pass

Repository Guard

Cargo dependency pinning

  • Status: pass
  • Every programs/*/Cargo.toml dep uses =x.y.z, a path = .. workspace ref, or a git dep with a 40-char rev.

Cross-program Anchor/Solana version consistency

  • Status: pass
  • anchor-lang and anchor-spl are pinned to the version declared in repo-guard.toml across every program.

solana-program crate pin

  • Status: pass
  • Every solana-program = "=X" declaration is =1.17.14 (locked to match Cargo.lock).

Anchor.toml solana_version

  • Status: pass
  • Anchor.toml declares solana_version = "1.17.34" (local-dev install for anchor test).

Crate minimum age

  • Status: pass
  • All Cargo deps changed by this PR are at least 14 days old on crates.io.

Yarn package.json pinning

  • Status: pass
  • All package.json deps use exact versions (no ^, ~, ranges).

npm minimum age

  • Status: pass
  • All npm deps changed by this PR are at least 14 days old.

Workflow toolchain consistency

  • Status: pass
  • Every workflow declares anchor-version: 0.29.0.
  • Per-file solana-cli-version values match [toolchain.workflow_solana_cli] in repo-guard.toml.

GitHub Action SHA pinning

  • Status: pass
  • Every third-party action is pinned to a SHA in [actions.sha_allowlist].

Sensitive program / config changes

  • Status: warn
  • Review hint only (CODEOWNERS is the merge gate). Lines below match heuristics for security-sensitive changes:
  • High-sensitivity files touched: Cargo.lock
  • scripts/v0.7/rip-cars/proposeUnlockTerms.ts:27 Hardcoded Solana address literal -> + "92NY2WWWNAfnMr8qyGXWXKbauhnR7d6ei9j9BGHVwh7r",
  • sdk/src/price_based_performance_package/v0.6/types/price_based_performance_package.ts:850 Hardcoded Solana address literal -> + name: "ChangePerformancePackageAuthorityParams";
  • sdk/src/price_based_performance_package/v0.6/types/price_based_performance_package.ts:1588 Hardcoded Solana address literal -> + name: "PerformancePackageAuthorityChanged";
  • sdk/src/price_based_performance_package/v0.6/types/price_based_performance_package.ts:2091 Hardcoded Solana address literal -> + name: "changePerformancePackageAuthority",
  • sdk/src/price_based_performance_package/v0.6/types/price_based_performance_package.ts:2118 Hardcoded Solana address literal -> + defined: "ChangePerformancePackageAuthorityParams",
  • sdk/src/price_based_performance_package/v0.6/types/price_based_performance_package.ts:2618 Hardcoded Solana address literal -> + name: "ChangePerformancePackageAuthorityParams",

Overall status: pass

Lockfile freshness (Cargo.lock + yarn.lock) is checked by the workflow directly and cannot be bypassed. The sensitive-diff section is a review hint - CODEOWNERS handles the actual merge gate.

@metapileks
metapileks marked this pull request as ready for review September 15, 2026 20:55
/// The recipient's ATA that receives the unlocked balance - created if needed
#[account(
init_if_needed,
payer = admin,

@metaproph3t metaproph3t Sep 21, 2026 •

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.

Will admin always be an EOA?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Any specific reason for this question?

The admin already isn't an EOA (it's a squads multisig) and this works just fine with it being a squads multisig. The vault can be a payer, it just has to have an adequate SOL balance, and there is no reentrancy.

LMK if something particular is bothering you here.

let locked = self
.total_token_amount
.checked_sub(self.already_unlocked_amount)
.ok_or(PriceBasedPerformancePackageError::InvariantViolated)?;

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.

I like this way of doing math since we can catch errors

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.

Or rather when they occur we can see where they are

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.

You've persuaded me

source_code: "https://github.com/metaDAOproject/programs",
source_release: "v0.6.0",
source_release: "v0.6.1",
policy: "The market will decide whether we pay a bug bounty.",

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.

Side note, but we should probably have a proper bug bounty program at this point

Comment on lines +50 to +56
#[access_control(ctx.accounts.validate_with_limits(&params))]
pub fn initialize_performance_package_with_limits(
ctx: Context<InitializePerformancePackage>,
params: InitializePerformancePackageWithLimitsParams,
) -> Result<()> {
InitializePerformancePackage::handle_with_limits(ctx, params)
}

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.

Why not just put this in regular initialize performance package? Seems somewhat silly to add an extra function

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Mainly to keep compatibility with our launchpad programs without redeploying them. If you disagree with my reasoning I'd be okay with making that change as well.

@metapileks
metapileks merged commit ee7f479 into develop Nov 3, 2026
4 checks passed
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.

2 participants