Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ca9818a
performance package withdraw limits scaffolding
metapileks Sep 11, 2026
17b8b20
pbpp withdrawal policy - migration
metapileks Sep 11, 2026
4a45b65
pbpp - gate actions before resize
metapileks Sep 11, 2026
cf94ebe
pbpp - token withdrawal mechanics - basic
metapileks Sep 11, 2026
1c73577
new pbpp burn package logic
metapileks Sep 11, 2026
c029b92
pbpp - init with limits
metapileks Sep 11, 2026
9e261ba
pbpp token withdrawal mechanism
metapileks Sep 13, 2026
6675ff6
pbpp - helpers for token withdrawal logic
metapileks Sep 14, 2026
221b4a2
pbpp - withdraw via sell mechanics
metapileks Sep 14, 2026
e756c88
propose and execute pbpp changes
metapileks Sep 14, 2026
3d5b0d9
pbpp - example token withdrawal with withdrawal limits on
metapileks Sep 14, 2026
c34243c
add scripts for migration and admin proposal of new package terms
metapileks Sep 14, 2026
2430998
optionally sweep quote account + stray quote tokens on performance pa…
metapileks Sep 14, 2026
83fc23a
burn pp - move quote sweep into a single argument client-side
metapileks Sep 16, 2026
64e1020
fix(pbpp): reject oracle whose base mint is not the package's mint
metapileks Sep 18, 2026
c073b52
fix(pbpp): pay the burn into any recipient-owned token account
metapileks Oct 5, 2026
ca69f3c
format run
metapileks Oct 5, 2026
f011b97
fix(pbpp): charge the sell route's quote cap at least the oracle valu…
metapileks Oct 5, 2026
45bf7b8
fix(pbpp): let the recipient bound what a token withdrawal is valued at
metapileks Oct 6, 2026
3a12689
fix(pbpp): roll the window usage before carrying it into replaced limits
metapileks Oct 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion programs/price_based_performance_package/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "price_based_performance_package"
version = "0.6.0"
version = "0.6.1"
description = "Created with Anchor"
edition = "2021"

Expand All @@ -19,4 +19,5 @@ production = []
[dependencies]
anchor-lang = { version = "=0.29.0", features = ["init-if-needed", "event-cpi"] }
anchor-spl = "=0.29.0"
futarchy = { path = "../futarchy", features = ["cpi"] }
solana-security-txt = "=1.1.1"
4 changes: 4 additions & 0 deletions programs/price_based_performance_package/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,7 @@ use anchor_lang::prelude::*;

#[constant]
pub const MAX_TRANCHES: usize = 10;

/// Scale of oracle prices: quote atoms per base atom, times 1e12
#[constant]
pub const PRICE_SCALE: u128 = 1_000_000_000_000;
24 changes: 24 additions & 0 deletions programs/price_based_performance_package/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,28 @@ pub enum PriceBasedPerformancePackageError {
TotalTokenAmountOverflow,
#[msg("Recipient and performance package authority must be different keys")]
RecipientAuthorityMustDiffer,
#[msg("Withdrawal limits must have non-zero caps, a future end, and a window of at least one second")]
InvalidWithdrawalLimits,
#[msg("Amount exceeds the withdrawable balance")]
InsufficientWithdrawableBalance,
#[msg("Token cap for the current window exceeded")]
TokenWindowLimitExceeded,
#[msg("Quote cap for the current window exceeded")]
QuoteWindowLimitExceeded,
#[msg("Oracle price observation is missing or zero")]
InvalidPriceObservation,
#[msg("Token withdrawals are disabled by the withdrawal mode")]
WithdrawTokensDisabled,
#[msg("Sell withdrawals are disabled by the withdrawal mode")]
WithdrawViaSellDisabled,
#[msg("Performance package has not been resized to the current layout")]
AccountNotMigrated,
#[msg("Quote mint must differ from the package's token mint")]
InvalidQuoteMint,
#[msg("The package's quote account and the quote destination must be passed together")]
QuoteSweepAccountsIncomplete,
#[msg("Oracle Dao's base mint must be the package's token mint")]
OracleMintMismatch,
#[msg("Withdrawal is valued above the recipient's maximum quote value")]
MaxQuoteValueExceeded,
}
35 changes: 34 additions & 1 deletion programs/price_based_performance_package/src/events.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::ChangeType;
use crate::{ChangeType, WindowUsage};
use anchor_lang::prelude::*;

#[derive(AnchorSerialize, AnchorDeserialize)]
Expand Down Expand Up @@ -43,6 +43,39 @@ pub struct UnlockCompleted {
pub twap_price: u128,
}

/// Present on a withdrawal that ran under active limits
#[derive(AnchorSerialize, AnchorDeserialize, Debug, Clone, Copy)]
pub struct CappedWithdrawal {
/// The price the withdrawal was valued at: the higher of the spot pool's observation and its reserve price
pub price: u128,
/// `amount` valued at that price, in quote atoms
pub quote_value: u64,
/// Window usage after this withdrawal
pub usage: WindowUsage,
}

#[event]
pub struct TokensWithdrawn {
pub common: CommonFields,
pub performance_package: Pubkey,
pub recipient: Pubkey,
pub amount: u64,
/// `None` when no limits were active
pub capped: Option<CappedWithdrawal>,
}

#[event]
pub struct TokensSold {
pub common: CommonFields,
pub performance_package: Pubkey,
pub recipient: Pubkey,
pub amount: u64,
pub quote_received: u64,
pub min_quote_out: u64,
/// Window usage after this sale; `None` when no limits were active
pub capped: Option<WindowUsage>,
}

#[event]
pub struct ChangeProposed {
pub common: CommonFields,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use anchor_lang::prelude::*;
use anchor_spl::token::{self, Burn, Mint, Token, TokenAccount};
use anchor_spl::token::{self, Burn, CloseAccount, Mint, Token, TokenAccount};

use super::*;

Expand All @@ -15,45 +15,108 @@ pub struct BurnPerformancePackage<'info> {
#[account(
mut,
close = spill_account,
has_one = recipient,
has_one = token_mint,
has_one = performance_package_token_vault
)]
pub performance_package: Box<Account<'info, PerformancePackage>>,

/// Emptied by the payout and the burn, then closed to the spill account
#[account(
mut,
associated_token::mint = token_mint,
associated_token::authority = performance_package
)]
pub performance_package_token_vault: Box<Account<'info, TokenAccount>>,

#[account(mut)]
/// CHECK: Pinned to the package's recipient by `has_one`
pub recipient: UncheckedAccount<'info>,

/// Any token account owned by the recipient; receives the unlocked balance
#[account(
mut,
token::mint = token_mint,
token::authority = recipient
)]
pub recipient_token_account: Box<Account<'info, TokenAccount>>,

pub admin: Signer<'info>,

/// CHECK: SOL from account closures go to this account
#[account(mut)]
pub spill_account: UncheckedAccount<'info>,

#[account(mut, address = performance_package.token_mint)]
pub token_mint: Account<'info, Mint>,
pub token_mint: Box<Account<'info, Mint>>,

/// The mint of the package's quote ATA; any mint other than the package's token mint
pub quote_mint: Option<Box<Account<'info, Mint>>>,

/// The package's quote ATA, swept into `quote_destination` and closed when passed
#[account(
mut,
associated_token::mint = quote_mint,
associated_token::authority = performance_package
)]
pub package_quote_account: Option<Box<Account<'info, TokenAccount>>>,

/// Where the quote balance goes, chosen by the admin
#[account(mut, token::mint = quote_mint)]
pub quote_destination: Option<Box<Account<'info, TokenAccount>>>,

pub token_program: Program<'info, Token>,
}

impl BurnPerformancePackage<'_> {
pub fn validate(&self) -> Result<()> {
PerformancePackage::assert_migrated(&self.performance_package.to_account_info())?;

#[cfg(feature = "production")]
require_keys_eq!(
self.admin.key(),
admin::ID,
PriceBasedPerformancePackageError::InvalidAdmin
);

// Ensure the quote mint is not the package's token mint.
if let Some(quote_mint) = &self.quote_mint {
require_keys_neq!(
quote_mint.key(),
self.token_mint.key(),
PriceBasedPerformancePackageError::InvalidQuoteMint
);
}

// Ensure the quote account and its destination are passed together.
require_eq!(
self.package_quote_account.is_some(),
self.quote_destination.is_some(),
PriceBasedPerformancePackageError::QuoteSweepAccountsIncomplete
);

Ok(())
}

pub fn handle(ctx: Context<Self>) -> Result<()> {
let performance_package = &ctx.accounts.performance_package;
let Self {
performance_package,
performance_package_token_vault,
recipient: _,
recipient_token_account,
admin: _,
spill_account,
token_mint,
quote_mint: _,
package_quote_account,
quote_destination,
token_program,
} = ctx.accounts;

let vault_amount = performance_package_token_vault.amount;
let withdrawable = performance_package.withdrawable(vault_amount)?;
let locked = vault_amount
.checked_sub(withdrawable)
.ok_or(PriceBasedPerformancePackageError::InvariantViolated)?;

let seeds = &[
b"performance_package",
Expand All @@ -62,25 +125,78 @@ impl BurnPerformancePackage<'_> {
];
let signer = &[&seeds[..]];

// Burn any remaining tokens in the performance package token vault
if ctx.accounts.performance_package_token_vault.amount > 0 {
// Hand the recipient what is already unlocked before burning the rest
if withdrawable > 0 {
token::transfer(
CpiContext::new_with_signer(
token_program.to_account_info(),
token::Transfer {
from: performance_package_token_vault.to_account_info(),
to: recipient_token_account.to_account_info(),
authority: performance_package.to_account_info(),
},
signer,
),
withdrawable,
)?;
}

if locked > 0 {
token::burn(
CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
token_program.to_account_info(),
Burn {
mint: ctx.accounts.token_mint.to_account_info(),
from: ctx
.accounts
.performance_package_token_vault
.to_account_info(),
mint: token_mint.to_account_info(),
from: performance_package_token_vault.to_account_info(),
authority: performance_package.to_account_info(),
},
signer,
),
ctx.accounts.performance_package_token_vault.amount,
locked,
)?;
}

// The vault is empty now, so its rent goes to the spill account
token::close_account(CpiContext::new_with_signer(
token_program.to_account_info(),
CloseAccount {
account: performance_package_token_vault.to_account_info(),
destination: spill_account.to_account_info(),
authority: performance_package.to_account_info(),
},
signer,
))?;

// Move whatever sits in the quote account to the admin's destination, then close it
if let (Some(package_quote_account), Some(quote_destination)) =
(package_quote_account, quote_destination)
{
if package_quote_account.amount > 0 {
token::transfer(
CpiContext::new_with_signer(
token_program.to_account_info(),
token::Transfer {
from: package_quote_account.to_account_info(),
to: quote_destination.to_account_info(),
authority: performance_package.to_account_info(),
},
signer,
),
package_quote_account.amount,
)?;
}

token::close_account(CpiContext::new_with_signer(
token_program.to_account_info(),
CloseAccount {
account: package_quote_account.to_account_info(),
destination: spill_account.to_account_info(),
authority: performance_package.to_account_info(),
},
signer,
))?;
}

// Performance package account gets closed using close constraint

Ok(())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ pub struct ChangePerformancePackageAuthority<'info> {

impl<'info> ChangePerformancePackageAuthority<'info> {
pub fn validate(&self, params: &ChangePerformancePackageAuthorityParams) -> Result<()> {
PerformancePackage::assert_migrated(&self.performance_package.to_account_info())?;

require_keys_neq!(
params.new_performance_package_authority,
self.performance_package.recipient,
Expand Down
Loading
Loading