diff --git a/.github/workflows/deploy-programs.yaml b/.github/workflows/deploy-programs.yaml index 96706d5ff..372870607 100644 --- a/.github/workflows/deploy-programs.yaml +++ b/.github/workflows/deploy-programs.yaml @@ -15,6 +15,7 @@ on: - price_based_performance_package_v6 - launchpad_v7 - bid_wall + - liquidation priority-fee: description: "Priority fee in microlamports" required: true @@ -41,6 +42,25 @@ jobs: MAINNET_MULTISIG: ${{ secrets.MAINNET_MULTISIG }} MAINNET_MULTISIG_VAULT: ${{ secrets.MAINNET_MULTISIG_VAULT }} + liquidation: + if: inputs.program == 'liquidation' || inputs.program == 'all' + uses: ./.github/workflows/reusable-build.yaml + with: + program: "liquidation" + override-program-id: "LiQnowFbFQdYyZhF4pUbpsrZCjxRTQ1upKJxZ2VXjde" + network: "mainnet" + deploy: true + upload_idl: true + verify: true + use-squads: true + features: "production" + priority-fee: ${{ inputs.priority-fee }} + secrets: + MAINNET_SOLANA_DEPLOY_URL: ${{ secrets.MAINNET_SOLANA_DEPLOY_URL }} + MAINNET_DEPLOYER_KEYPAIR: ${{ secrets.MAINNET_DEPLOYER_KEYPAIR }} + MAINNET_MULTISIG: ${{ secrets.MAINNET_MULTISIG }} + MAINNET_MULTISIG_VAULT: ${{ secrets.MAINNET_MULTISIG_VAULT }} + futarchy-v6: if: inputs.program == 'futarchy_v6' || inputs.program == 'all' uses: ./.github/workflows/reusable-build.yaml diff --git a/.github/workflows/generate-verifiable-builds.yaml b/.github/workflows/generate-verifiable-builds.yaml index daa2d8267..530473885 100644 --- a/.github/workflows/generate-verifiable-builds.yaml +++ b/.github/workflows/generate-verifiable-builds.yaml @@ -111,4 +111,21 @@ jobs: uses: EndBug/add-and-commit@v9.1.4 with: default_author: github_actions - message: 'Update bid_wall verifiable build' \ No newline at end of file + message: 'Update bid_wall verifiable build' + generate-verifiable-liquidation: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: metadaoproject/anchor-verifiable-build@v0.4 + with: + program: liquidation + anchor-version: '0.29.0' + solana-cli-version: '1.17.31' + features: 'production' + - run: 'git pull --rebase' + - run: cp target/deploy/liquidation.so ./verifiable-builds + - name: Commit verifiable build back to mainline + uses: EndBug/add-and-commit@v9.1.4 + with: + default_author: github_actions + message: 'Update liquidation verifiable build' \ No newline at end of file diff --git a/Anchor.toml b/Anchor.toml index 5cda24af4..fab545811 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -11,6 +11,7 @@ conditional_vault = "VLTX1ishMBbcX3rdBWGssxawAo1Q2X2qxYFYqiGodVg" futarchy = "FUTARELBfJfQ8RDGhg1wdhddq1odMAJUePHFuBYfUxKq" launchpad = "MooNyh4CBUYEKyXVnjGYQ8mEiJDpGvJMdvrZx1iGeHV" launchpad_v7 = "moontUzsdepotRGe5xsfip7vLPTJnVuafqdUWexVnPM" +liquidation = "LiQnowFbFQdYyZhF4pUbpsrZCjxRTQ1upKJxZ2VXjde" mint_governor = "gvnr27cVeyW3AVf3acL7VCJ5WjGAphytnsgcK1feHyH" performance_package_v2 = "pPV2pfrxnmstSb9j7kEeCLny5BGj6SNwCWGd6xbGGzz" price_based_performance_package = "pbPPQH7jyKoSLu8QYs3rSY3YkDRXEBojKbTgnUg7NDS" diff --git a/CLAUDE.md b/CLAUDE.md index 5aa085d9a..9140490e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -148,6 +148,9 @@ pub recipient_ata: Account<'info, TokenAccount>, pub funder_token_account: Account<'info, TokenAccount>, ``` +### Events +Always use CPI events (`#[event_cpi]` on accounts structs, `emit_cpi!` for emission) rather than regular `emit!`. + ### Require Macros When writing validation checks, prefer specific require macros over generic `require!`: 1. `require_keys_eq!` - when comparing two `Pubkey` values @@ -289,3 +292,4 @@ External programs required for tests. These are pre-compiled `.so` files in `tes | conditional_vault | v0.4 | `VLTX1ishMBbcX3rdBWGssxawAo1Q2X2qxYFYqiGodVg` | | price_based_performance_package | v0.6.0 | `pbPPQH7jyKoSLu8QYs3rSY3YkDRXEBojKbTgnUg7NDS` | | mint_governor | v0.7.0 | `gvnr27cVeyW3AVf3acL7VCJ5WjGAphytnsgcK1feHyH` | +| liquidation | v0.1.0 | `LiQnowFbFQdYyZhF4pUbpsrZCjxRTQ1upKJxZ2VXjde` | diff --git a/Cargo.lock b/Cargo.lock index 8a51feafc..99592050d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1257,6 +1257,15 @@ dependencies = [ "thiserror", ] +[[package]] +name = "liquidation" +version = "0.1.0" +dependencies = [ + "anchor-lang", + "anchor-spl", + "solana-security-txt", +] + [[package]] name = "lock_api" version = "0.4.13" diff --git a/README.md b/README.md index d3a850e6a..b8a701e75 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Programs for unruggable capital formation and market-driven governance. | ----------------- | ---- | -------------------------------------------- | | launchpad | v0.7.0 | moontUzsdepotRGe5xsfip7vLPTJnVuafqdUWexVnPM | | bid_wall | v0.7.0 | WALL8ucBuUyL46QYxwYJjidaFYhdvxUFrgvBxPshERx | +| liquidation | v0.1.0 | LiQnowFbFQdYyZhF4pUbpsrZCjxRTQ1upKJxZ2VXjde | | futarchy | v0.6.0 | FUTARELBfJfQ8RDGhg1wdhddq1odMAJUePHFuBYfUxKq | | launchpad | v0.6.0 | MooNyh4CBUYEKyXVnjGYQ8mEiJDpGvJMdvrZx1iGeHV | | price_based_performance_package | v0.6.0 | pbPPQH7jyKoSLu8QYs3rSY3YkDRXEBojKbTgnUg7NDS | diff --git a/programs/liquidation/Cargo.toml b/programs/liquidation/Cargo.toml new file mode 100644 index 000000000..b567ab434 --- /dev/null +++ b/programs/liquidation/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "liquidation" +version = "0.1.0" +description = "Manages the orderly liquidation of a project's treasury back to token holders." +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "liquidation" + +[features] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +cpi = ["no-entrypoint"] +default = [] +production = [] + +[dependencies] +anchor-lang = { version = "0.29.0", features = ["event-cpi", "init-if-needed"] } +anchor-spl = "0.29.0" +solana-security-txt = "1.1.1" diff --git a/programs/liquidation/Xargo.toml b/programs/liquidation/Xargo.toml new file mode 100644 index 000000000..475fb71ed --- /dev/null +++ b/programs/liquidation/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] diff --git a/programs/liquidation/src/error.rs b/programs/liquidation/src/error.rs new file mode 100644 index 000000000..e920c4877 --- /dev/null +++ b/programs/liquidation/src/error.rs @@ -0,0 +1,27 @@ +use super::*; + +#[error_code] +pub enum LiquidationError { + #[msg("Refunding is not enabled")] + RefundingNotEnabled, + #[msg("Liquidation is already activated")] + AlreadyActivated, + #[msg("No quote tokens to fund")] + NothingToFund, + #[msg("No base tokens assigned")] + NoBaseAssigned, + #[msg("Refund window has expired")] + RefundWindowExpired, + #[msg("Refund window has not expired")] + RefundWindowNotExpired, + #[msg("Duration must be greater than zero")] + InvalidDuration, + #[msg("Nothing to refund")] + NothingToRefund, + #[msg("Invalid allocation")] + InvalidAllocation, + #[msg("Invalid authority")] + InvalidAuthority, + #[msg("Invalid mint")] + InvalidMint, +} diff --git a/programs/liquidation/src/events.rs b/programs/liquidation/src/events.rs new file mode 100644 index 000000000..759b89945 --- /dev/null +++ b/programs/liquidation/src/events.rs @@ -0,0 +1,74 @@ +use anchor_lang::prelude::*; + +#[derive(AnchorSerialize, AnchorDeserialize)] +pub struct CommonFields { + pub slot: u64, + pub unix_timestamp: i64, + pub liquidation_seq_num: u64, +} + +impl CommonFields { + pub fn new(clock: &Clock, liquidation_seq_num: u64) -> Self { + Self { + slot: clock.slot, + unix_timestamp: clock.unix_timestamp, + liquidation_seq_num, + } + } +} + +#[event] +pub struct LiquidationCreatedEvent { + pub common: CommonFields, + pub liquidation: Pubkey, + pub create_key: Pubkey, + pub record_authority: Pubkey, + pub liquidation_authority: Pubkey, + pub base_mint: Pubkey, + pub quote_mint: Pubkey, + pub duration_seconds: u32, + pub pda_bump: u8, +} + +#[event] +pub struct LiquidationActivatedEvent { + pub common: CommonFields, + pub liquidation: Pubkey, + pub total_quote_funded: u64, + pub started_at: i64, +} + +#[event] +pub struct RefundRecordSetEvent { + pub common: CommonFields, + pub liquidation: Pubkey, + pub refund_record: Pubkey, + pub recipient: Pubkey, + pub base_assigned: u64, + pub quote_refundable: u64, + pub liquidation_total_base_assigned: u64, + pub liquidation_total_quote_refundable: u64, + pub pda_bump: u8, +} + +#[event] +pub struct RefundEvent { + pub common: CommonFields, + pub liquidation: Pubkey, + pub refund_record: Pubkey, + pub recipient: Pubkey, + pub base_burned: u64, + pub quote_refunded: u64, + pub post_record_base_burned: u64, + pub post_record_quote_refunded: u64, + pub post_liquidation_total_base_burned: u64, + pub post_liquidation_total_quote_refunded: u64, +} + +#[event] +pub struct WithdrawRemainingQuoteEvent { + pub common: CommonFields, + pub liquidation: Pubkey, + pub liquidation_authority: Pubkey, + pub amount: u64, +} diff --git a/programs/liquidation/src/instructions/activate_liquidation.rs b/programs/liquidation/src/instructions/activate_liquidation.rs new file mode 100644 index 000000000..ccf191bcc --- /dev/null +++ b/programs/liquidation/src/instructions/activate_liquidation.rs @@ -0,0 +1,99 @@ +use anchor_lang::prelude::*; +use anchor_spl::token::{self, Mint, Token, TokenAccount, Transfer}; + +use crate::{ + error::LiquidationError, + events::{CommonFields, LiquidationActivatedEvent}, + state::Liquidation, +}; + +#[event_cpi] +#[derive(Accounts)] +pub struct ActivateLiquidation<'info> { + pub liquidation_authority: Signer<'info>, + + #[account( + mut, + has_one = liquidation_authority @ LiquidationError::InvalidAuthority, + has_one = quote_mint @ LiquidationError::InvalidMint, + )] + pub liquidation: Account<'info, Liquidation>, + + #[account( + mut, + token::mint = quote_mint, + token::authority = liquidation_authority, + )] + pub liquidation_authority_quote_account: Account<'info, TokenAccount>, + + #[account( + mut, + associated_token::mint = quote_mint, + associated_token::authority = liquidation, + )] + pub liquidation_quote_vault: Account<'info, TokenAccount>, + + pub quote_mint: Account<'info, Mint>, + + pub token_program: Program<'info, Token>, +} + +impl ActivateLiquidation<'_> { + pub fn validate(&self) -> Result<()> { + require!( + !self.liquidation.is_refunding, + LiquidationError::AlreadyActivated + ); + + require_gt!( + self.liquidation.total_base_assigned, + 0, + LiquidationError::NoBaseAssigned + ); + + require_gt!( + self.liquidation.total_quote_refundable, + 0, + LiquidationError::NothingToFund + ); + + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let clock = Clock::get()?; + let liquidation = &mut ctx.accounts.liquidation; + + // Transfer total_quote_refundable from authority to vault + token::transfer( + CpiContext::new( + ctx.accounts.token_program.to_account_info(), + Transfer { + from: ctx + .accounts + .liquidation_authority_quote_account + .to_account_info(), + to: ctx.accounts.liquidation_quote_vault.to_account_info(), + authority: ctx.accounts.liquidation_authority.to_account_info(), + }, + ), + liquidation.total_quote_refundable, + )?; + + // Permanently enable refunding + liquidation.started_at = clock.unix_timestamp; + liquidation.is_refunding = true; + + // Emit event + liquidation.seq_num += 1; + + emit_cpi!(LiquidationActivatedEvent { + common: CommonFields::new(&clock, liquidation.seq_num), + liquidation: liquidation.key(), + total_quote_funded: liquidation.total_quote_refundable, + started_at: liquidation.started_at, + }); + + Ok(()) + } +} diff --git a/programs/liquidation/src/instructions/initialize_liquidation.rs b/programs/liquidation/src/instructions/initialize_liquidation.rs new file mode 100644 index 000000000..63ac1c313 --- /dev/null +++ b/programs/liquidation/src/instructions/initialize_liquidation.rs @@ -0,0 +1,104 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token::{Mint, Token, TokenAccount}, +}; + +use crate::{ + error::LiquidationError, + events::{CommonFields, LiquidationCreatedEvent}, + state::{Liquidation, SEED_LIQUIDATION}, +}; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct InitializeLiquidationArgs { + pub duration_seconds: u32, +} + +#[event_cpi] +#[derive(Accounts)] +pub struct InitializeLiquidation<'info> { + #[account(mut)] + pub payer: Signer<'info>, + + pub create_key: Signer<'info>, + + /// CHECK: Stored on the Liquidation account as the record authority. + pub record_authority: UncheckedAccount<'info>, + /// CHECK: Stored on the Liquidation account as the liquidation authority. + pub liquidation_authority: UncheckedAccount<'info>, + + pub base_mint: Account<'info, Mint>, + pub quote_mint: Account<'info, Mint>, + + #[account( + init, + payer = payer, + space = 8 + Liquidation::INIT_SPACE, + seeds = [SEED_LIQUIDATION, base_mint.key().as_ref(), quote_mint.key().as_ref(), create_key.key().as_ref()], + bump + )] + pub liquidation: Account<'info, Liquidation>, + + #[account( + init_if_needed, + payer = payer, + associated_token::mint = quote_mint, + associated_token::authority = liquidation, + )] + pub liquidation_quote_vault: Account<'info, TokenAccount>, + + pub system_program: Program<'info, System>, + pub token_program: Program<'info, Token>, + pub associated_token_program: Program<'info, AssociatedToken>, +} + +impl InitializeLiquidation<'_> { + pub fn validate(&self, args: &InitializeLiquidationArgs) -> Result<()> { + // Mints must be different + require_keys_neq!( + self.base_mint.key(), + self.quote_mint.key(), + LiquidationError::InvalidMint + ); + + // Refund window must have a nonzero duration + require_gt!(args.duration_seconds, 0, LiquidationError::InvalidDuration); + Ok(()) + } + + pub fn handle(ctx: Context, args: InitializeLiquidationArgs) -> Result<()> { + let clock = Clock::get()?; + + ctx.accounts.liquidation.set_inner(Liquidation { + create_key: ctx.accounts.create_key.key(), + record_authority: ctx.accounts.record_authority.key(), + liquidation_authority: ctx.accounts.liquidation_authority.key(), + base_mint: ctx.accounts.base_mint.key(), + quote_mint: ctx.accounts.quote_mint.key(), + total_quote_refundable: 0, + total_quote_refunded: 0, + total_base_assigned: 0, + total_base_burned: 0, + started_at: 0, + duration_seconds: args.duration_seconds, + seq_num: 0, + is_refunding: false, + pda_bump: ctx.bumps.liquidation, + }); + + emit_cpi!(LiquidationCreatedEvent { + common: CommonFields::new(&clock, ctx.accounts.liquidation.seq_num), + liquidation: ctx.accounts.liquidation.key(), + create_key: ctx.accounts.create_key.key(), + record_authority: ctx.accounts.record_authority.key(), + liquidation_authority: ctx.accounts.liquidation_authority.key(), + base_mint: ctx.accounts.base_mint.key(), + quote_mint: ctx.accounts.quote_mint.key(), + duration_seconds: args.duration_seconds, + pda_bump: ctx.bumps.liquidation, + }); + + Ok(()) + } +} diff --git a/programs/liquidation/src/instructions/mod.rs b/programs/liquidation/src/instructions/mod.rs new file mode 100644 index 000000000..d3376a652 --- /dev/null +++ b/programs/liquidation/src/instructions/mod.rs @@ -0,0 +1,11 @@ +pub mod activate_liquidation; +pub mod initialize_liquidation; +pub mod refund; +pub mod set_refund_record; +pub mod withdraw_remaining_quote; + +pub use activate_liquidation::*; +pub use initialize_liquidation::*; +pub use refund::*; +pub use set_refund_record::*; +pub use withdraw_remaining_quote::*; diff --git a/programs/liquidation/src/instructions/refund.rs b/programs/liquidation/src/instructions/refund.rs new file mode 100644 index 000000000..014291079 --- /dev/null +++ b/programs/liquidation/src/instructions/refund.rs @@ -0,0 +1,168 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token::{self, Burn, Mint, Token, TokenAccount, Transfer}, +}; + +use crate::{ + error::LiquidationError, + events::{CommonFields, RefundEvent}, + state::{Liquidation, RefundRecord, SEED_LIQUIDATION}, +}; + +#[event_cpi] +#[derive(Accounts)] +pub struct Refund<'info> { + #[account(mut)] + pub recipient: Signer<'info>, + + #[account( + mut, + has_one = base_mint @ LiquidationError::InvalidMint, + has_one = quote_mint @ LiquidationError::InvalidMint, + seeds = [SEED_LIQUIDATION, base_mint.key().as_ref(), quote_mint.key().as_ref(), liquidation.create_key.as_ref()], + bump = liquidation.pda_bump, + )] + pub liquidation: Account<'info, Liquidation>, + + #[account( + mut, + has_one = liquidation @ LiquidationError::InvalidAuthority, + has_one = recipient @ LiquidationError::InvalidAuthority, + )] + pub refund_record: Account<'info, RefundRecord>, + + #[account( + mut, + token::mint = base_mint, + token::authority = recipient, + )] + pub recipient_base_account: Account<'info, TokenAccount>, + + #[account( + mut, + associated_token::mint = quote_mint, + associated_token::authority = liquidation, + )] + pub liquidation_quote_vault: Account<'info, TokenAccount>, + + #[account( + init_if_needed, + payer = recipient, + associated_token::mint = quote_mint, + associated_token::authority = recipient, + )] + pub recipient_quote_account: Account<'info, TokenAccount>, + + #[account(mut)] + pub base_mint: Account<'info, Mint>, + pub quote_mint: Account<'info, Mint>, + + pub token_program: Program<'info, Token>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} + +impl Refund<'_> { + pub fn validate(&self) -> Result<()> { + let clock = Clock::get()?; + + require!( + self.liquidation.is_refunding, + LiquidationError::RefundingNotEnabled + ); + + require_gte!( + self.liquidation.started_at + self.liquidation.duration_seconds as i64, + clock.unix_timestamp, + LiquidationError::RefundWindowExpired + ); + + let remaining_burnable = self.refund_record.base_assigned - self.refund_record.base_burned; + let effective_burn = remaining_burnable.min(self.recipient_base_account.amount); + + require_gt!(effective_burn, 0, LiquidationError::NothingToRefund); + + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let clock = Clock::get()?; + let refund_record = &mut ctx.accounts.refund_record; + + // Compute effective burn + let remaining_burnable = refund_record.base_assigned - refund_record.base_burned; + let effective_burn = remaining_burnable.min(ctx.accounts.recipient_base_account.amount); + + // Burn base tokens from recipient + token::burn( + CpiContext::new( + ctx.accounts.token_program.to_account_info(), + Burn { + mint: ctx.accounts.base_mint.to_account_info(), + from: ctx.accounts.recipient_base_account.to_account_info(), + authority: ctx.accounts.recipient.to_account_info(), + }, + ), + effective_burn, + )?; + + // Update base_burned totals + let liquidation = &mut ctx.accounts.liquidation; + + refund_record.base_burned += effective_burn; + liquidation.total_base_burned += effective_burn; + + // Compute quote owed + let quote_owed = (refund_record.quote_refundable as u128 + * refund_record.base_burned as u128 + / refund_record.base_assigned as u128) as u64; + + // Compute transfer amount + let quote_transfer = quote_owed - refund_record.quote_refunded; + + // Transfer quote tokens from vault to recipient (PDA-signed) + let signer_seeds: &[&[&[u8]]] = &[&[ + SEED_LIQUIDATION, + liquidation.base_mint.as_ref(), + liquidation.quote_mint.as_ref(), + liquidation.create_key.as_ref(), + &[liquidation.pda_bump], + ]]; + + token::transfer( + CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + Transfer { + from: ctx.accounts.liquidation_quote_vault.to_account_info(), + to: ctx.accounts.recipient_quote_account.to_account_info(), + authority: liquidation.to_account_info(), + }, + signer_seeds, + ), + quote_transfer, + )?; + + // Update quote_refunded totals + refund_record.quote_refunded += quote_transfer; + liquidation.total_quote_refunded += quote_transfer; + + // Emit event + liquidation.seq_num += 1; + + emit_cpi!(RefundEvent { + common: CommonFields::new(&clock, liquidation.seq_num), + liquidation: liquidation.key(), + refund_record: refund_record.key(), + recipient: ctx.accounts.recipient.key(), + base_burned: effective_burn, + quote_refunded: quote_transfer, + post_record_base_burned: refund_record.base_burned, + post_record_quote_refunded: refund_record.quote_refunded, + post_liquidation_total_base_burned: liquidation.total_base_burned, + post_liquidation_total_quote_refunded: liquidation.total_quote_refunded, + }); + + Ok(()) + } +} diff --git a/programs/liquidation/src/instructions/set_refund_record.rs b/programs/liquidation/src/instructions/set_refund_record.rs new file mode 100644 index 000000000..143291fdd --- /dev/null +++ b/programs/liquidation/src/instructions/set_refund_record.rs @@ -0,0 +1,117 @@ +use std::cmp::Ordering; + +use anchor_lang::prelude::*; + +use crate::{ + error::LiquidationError, + events::{CommonFields, RefundRecordSetEvent}, + state::{Liquidation, RefundRecord, SEED_REFUND_RECORD}, +}; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +pub struct SetRefundRecordArgs { + pub base_assigned: u64, + pub quote_refundable: u64, +} + +#[event_cpi] +#[derive(Accounts)] +pub struct SetRefundRecord<'info> { + #[account(mut)] + pub payer: Signer<'info>, + + pub record_authority: Signer<'info>, + + #[account( + mut, + constraint = liquidation.record_authority == record_authority.key() @ LiquidationError::InvalidAuthority, + )] + pub liquidation: Account<'info, Liquidation>, + + /// CHECK: The user this record is for. Does not need to sign. + pub recipient: UncheckedAccount<'info>, + + #[account( + init_if_needed, + payer = payer, + space = 8 + RefundRecord::INIT_SPACE, + seeds = [SEED_REFUND_RECORD, liquidation.key().as_ref(), recipient.key().as_ref()], + bump, + )] + pub refund_record: Account<'info, RefundRecord>, + + pub system_program: Program<'info, System>, +} + +impl SetRefundRecord<'_> { + pub fn validate(&self, args: &SetRefundRecordArgs) -> Result<()> { + // Records can only be set during the setup phase + require!( + !self.liquidation.is_refunding, + LiquidationError::AlreadyActivated + ); + + // If quote is allocated but base_assigned is zero, the user can never burn tokens + // to claim their quote — funds would be locked until post-deadline withdrawal + if args.quote_refundable > 0 { + require_gt!(args.base_assigned, 0, LiquidationError::InvalidAllocation); + } + + Ok(()) + } + + pub fn handle(ctx: Context, args: SetRefundRecordArgs) -> Result<()> { + let clock = Clock::get()?; + let liquidation = &mut ctx.accounts.liquidation; + let refund_record = &mut ctx.accounts.refund_record; + + // Adjust liquidation totals based on diff + match args.base_assigned.cmp(&refund_record.base_assigned) { + Ordering::Greater => { + liquidation.total_base_assigned += args.base_assigned - refund_record.base_assigned; + } + Ordering::Less => { + liquidation.total_base_assigned -= refund_record.base_assigned - args.base_assigned; + } + Ordering::Equal => {} + } + + match args.quote_refundable.cmp(&refund_record.quote_refundable) { + Ordering::Greater => { + liquidation.total_quote_refundable += + args.quote_refundable - refund_record.quote_refundable; + } + Ordering::Less => { + liquidation.total_quote_refundable -= + refund_record.quote_refundable - args.quote_refundable; + } + Ordering::Equal => {} + } + + // Set refund record fields + refund_record.liquidation = liquidation.key(); + refund_record.recipient = ctx.accounts.recipient.key(); + refund_record.base_assigned = args.base_assigned; + refund_record.quote_refundable = args.quote_refundable; + refund_record.base_burned = 0; + refund_record.quote_refunded = 0; + refund_record.pda_bump = ctx.bumps.refund_record; + + // Emit event + liquidation.seq_num += 1; + + emit_cpi!(RefundRecordSetEvent { + common: CommonFields::new(&clock, liquidation.seq_num), + liquidation: liquidation.key(), + refund_record: ctx.accounts.refund_record.key(), + recipient: ctx.accounts.recipient.key(), + base_assigned: args.base_assigned, + quote_refundable: args.quote_refundable, + liquidation_total_base_assigned: liquidation.total_base_assigned, + liquidation_total_quote_refundable: liquidation.total_quote_refundable, + pda_bump: ctx.bumps.refund_record, + }); + + Ok(()) + } +} diff --git a/programs/liquidation/src/instructions/withdraw_remaining_quote.rs b/programs/liquidation/src/instructions/withdraw_remaining_quote.rs new file mode 100644 index 000000000..d59a43462 --- /dev/null +++ b/programs/liquidation/src/instructions/withdraw_remaining_quote.rs @@ -0,0 +1,102 @@ +use anchor_lang::prelude::*; +use anchor_spl::token::{self, Mint, Token, TokenAccount, Transfer}; + +use crate::{ + error::LiquidationError, + events::{CommonFields, WithdrawRemainingQuoteEvent}, + state::{Liquidation, SEED_LIQUIDATION}, +}; + +#[event_cpi] +#[derive(Accounts)] +pub struct WithdrawRemainingQuote<'info> { + pub liquidation_authority: Signer<'info>, + + #[account( + mut, + has_one = liquidation_authority @ LiquidationError::InvalidAuthority, + has_one = quote_mint @ LiquidationError::InvalidMint, + seeds = [SEED_LIQUIDATION, liquidation.base_mint.as_ref(), quote_mint.key().as_ref(), liquidation.create_key.as_ref()], + bump = liquidation.pda_bump, + )] + pub liquidation: Account<'info, Liquidation>, + + #[account( + mut, + associated_token::mint = quote_mint, + associated_token::authority = liquidation, + )] + pub liquidation_quote_vault: Account<'info, TokenAccount>, + + #[account( + mut, + token::mint = quote_mint, + token::authority = liquidation_authority, + )] + pub liquidation_authority_quote_account: Account<'info, TokenAccount>, + + pub quote_mint: Account<'info, Mint>, + + pub token_program: Program<'info, Token>, +} + +impl WithdrawRemainingQuote<'_> { + pub fn validate(&self) -> Result<()> { + let clock = Clock::get()?; + + require!( + self.liquidation.is_refunding, + LiquidationError::RefundingNotEnabled + ); + + require_gt!( + clock.unix_timestamp, + self.liquidation.started_at + self.liquidation.duration_seconds as i64, + LiquidationError::RefundWindowNotExpired + ); + + Ok(()) + } + + pub fn handle(ctx: Context) -> Result<()> { + let clock = Clock::get()?; + let liquidation = &mut ctx.accounts.liquidation; + + let amount = ctx.accounts.liquidation_quote_vault.amount; + + let signer_seeds: &[&[&[u8]]] = &[&[ + SEED_LIQUIDATION, + liquidation.base_mint.as_ref(), + liquidation.quote_mint.as_ref(), + liquidation.create_key.as_ref(), + &[liquidation.pda_bump], + ]]; + + token::transfer( + CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + Transfer { + from: ctx.accounts.liquidation_quote_vault.to_account_info(), + to: ctx + .accounts + .liquidation_authority_quote_account + .to_account_info(), + authority: liquidation.to_account_info(), + }, + signer_seeds, + ), + amount, + )?; + + liquidation.seq_num += 1; + + emit_cpi!(WithdrawRemainingQuoteEvent { + common: CommonFields::new(&clock, liquidation.seq_num), + liquidation: liquidation.key(), + liquidation_authority: ctx.accounts.liquidation_authority.key(), + amount, + }); + + Ok(()) + } +} diff --git a/programs/liquidation/src/lib.rs b/programs/liquidation/src/lib.rs new file mode 100644 index 000000000..3b9ab86b5 --- /dev/null +++ b/programs/liquidation/src/lib.rs @@ -0,0 +1,61 @@ +//! Manages the orderly liquidation of a project's treasury back to token holders. +use anchor_lang::prelude::*; + +pub mod error; +pub mod events; +pub mod instructions; +pub mod state; + +use instructions::*; + +#[cfg(not(feature = "no-entrypoint"))] +use solana_security_txt::security_txt; + +#[cfg(not(feature = "no-entrypoint"))] +security_txt! { + name: "liquidation", + project_url: "https://metadao.fi", + contacts: "telegram:metaproph3t,telegram:kollan_house", + source_code: "https://github.com/metaDAOproject/programs", + source_release: "v0.1.0", + policy: "The market will decide whether we pay a bug bounty.", + acknowledgements: "DCF = (CF1 / (1 + r)^1) + (CF2 / (1 + r)^2) + ... (CFn / (1 + r)^n)" +} + +declare_id!("LiQnowFbFQdYyZhF4pUbpsrZCjxRTQ1upKJxZ2VXjde"); + +#[program] +pub mod liquidation { + use super::*; + + #[access_control(ctx.accounts.validate(&args))] + pub fn initialize_liquidation( + ctx: Context, + args: InitializeLiquidationArgs, + ) -> Result<()> { + InitializeLiquidation::handle(ctx, args) + } + + #[access_control(ctx.accounts.validate(&args))] + pub fn set_refund_record( + ctx: Context, + args: SetRefundRecordArgs, + ) -> Result<()> { + SetRefundRecord::handle(ctx, args) + } + + #[access_control(ctx.accounts.validate())] + pub fn activate_liquidation(ctx: Context) -> Result<()> { + ActivateLiquidation::handle(ctx) + } + + #[access_control(ctx.accounts.validate())] + pub fn refund(ctx: Context) -> Result<()> { + Refund::handle(ctx) + } + + #[access_control(ctx.accounts.validate())] + pub fn withdraw_remaining_quote(ctx: Context) -> Result<()> { + WithdrawRemainingQuote::handle(ctx) + } +} diff --git a/programs/liquidation/src/state/liquidation.rs b/programs/liquidation/src/state/liquidation.rs new file mode 100644 index 000000000..f8c1ffa6f --- /dev/null +++ b/programs/liquidation/src/state/liquidation.rs @@ -0,0 +1,36 @@ +use anchor_lang::prelude::*; + +pub const SEED_LIQUIDATION: &[u8] = b"liquidation"; + +#[account] +#[derive(InitSpace)] +pub struct Liquidation { + /// Arbitrary keypair used to make the PDA unique. + pub create_key: Pubkey, + /// The address that can create and modify RefundRecords during setup. + pub record_authority: Pubkey, + /// The address that activates the liquidation and receives remaining quote tokens post-deadline. + pub liquidation_authority: Pubkey, + /// The project token mint (tokens to be burned). + pub base_mint: Pubkey, + /// The refund token mint (USDC). + pub quote_mint: Pubkey, + /// Sum of all RefundRecord quote_refundable values. + pub total_quote_refundable: u64, + /// Sum of all quote tokens actually transferred to users so far. + pub total_quote_refunded: u64, + /// Sum of all RefundRecord base_assigned values. + pub total_base_assigned: u64, + /// Sum of all base tokens actually burned so far. + pub total_base_burned: u64, + /// Unix timestamp when ActivateLiquidation was called. 0 before activation. + pub started_at: i64, + /// How long the refund window lasts after activation. + pub duration_seconds: u32, + /// Event sequence number for indexing. + pub seq_num: u64, + /// Whether refunds are currently enabled. + pub is_refunding: bool, + /// PDA bump seed. + pub pda_bump: u8, +} diff --git a/programs/liquidation/src/state/mod.rs b/programs/liquidation/src/state/mod.rs new file mode 100644 index 000000000..c5632ad22 --- /dev/null +++ b/programs/liquidation/src/state/mod.rs @@ -0,0 +1,5 @@ +pub mod liquidation; +pub mod refund_record; + +pub use liquidation::*; +pub use refund_record::*; diff --git a/programs/liquidation/src/state/refund_record.rs b/programs/liquidation/src/state/refund_record.rs new file mode 100644 index 000000000..c4660665c --- /dev/null +++ b/programs/liquidation/src/state/refund_record.rs @@ -0,0 +1,22 @@ +use anchor_lang::prelude::*; + +pub const SEED_REFUND_RECORD: &[u8] = b"refund_record"; + +#[account] +#[derive(InitSpace)] +pub struct RefundRecord { + /// The parent Liquidation account. + pub liquidation: Pubkey, + /// The user this record belongs to. + pub recipient: Pubkey, + /// Total base tokens this user is eligible to burn. + pub base_assigned: u64, + /// Base tokens this user has burned so far. + pub base_burned: u64, + /// Total quote tokens this user can receive if they burn all assigned base. + pub quote_refundable: u64, + /// Quote tokens already transferred to this user. + pub quote_refunded: u64, + /// PDA bump seed. + pub pda_bump: u8, +} diff --git a/sdk/package.json b/sdk/package.json index 60d42aa6c..a0706d551 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@metadaoproject/futarchy", - "version": "0.7.0-alpha.14", + "version": "0.7.1-alpha.5", "type": "module", "main": "dist/index.js", "module": "dist/index.js", diff --git a/sdk/src/v0.7/LiquidationClient.ts b/sdk/src/v0.7/LiquidationClient.ts new file mode 100644 index 000000000..1c0724330 --- /dev/null +++ b/sdk/src/v0.7/LiquidationClient.ts @@ -0,0 +1,338 @@ +import { AnchorProvider, Program } from "@coral-xyz/anchor"; +import BN from "bn.js"; +import { AccountInfo, PublicKey, SystemProgram } from "@solana/web3.js"; +import { + ASSOCIATED_TOKEN_PROGRAM_ID, + getAssociatedTokenAddressSync, + TOKEN_PROGRAM_ID, +} from "@solana/spl-token"; +import { LIQUIDATION_PROGRAM_ID } from "../v0.7/constants.js"; +import { + LiquidationProgram, + LiquidationIDL, + LiquidationAccount, + RefundRecordAccount, +} from "../v0.7/types/index.js"; +import { + getLiquidationAddr, + getRefundRecordAddr, + getEventAuthorityAddr, +} from "../v0.7/utils/pda.js"; + +export type CreateLiquidationClientParams = { + provider: AnchorProvider; + liquidationProgramId?: PublicKey; +}; + +export class LiquidationClient { + public readonly provider: AnchorProvider; + public readonly liquidationProgram: Program; + public readonly programId: PublicKey; + + constructor(provider: AnchorProvider, liquidationProgramId: PublicKey) { + this.provider = provider; + this.programId = liquidationProgramId; + this.liquidationProgram = new Program( + LiquidationIDL, + liquidationProgramId, + provider, + ); + } + + public static createClient( + createLiquidationClientParams: CreateLiquidationClientParams, + ): LiquidationClient { + let { provider, liquidationProgramId } = createLiquidationClientParams; + + return new LiquidationClient( + provider, + liquidationProgramId || LIQUIDATION_PROGRAM_ID, + ); + } + + public getProgramId(): PublicKey { + return this.programId; + } + + async fetchLiquidation( + liquidation: PublicKey, + ): Promise { + return this.liquidationProgram.account.liquidation.fetchNullable( + liquidation, + ); + } + + async deserializeLiquidation( + accountInfo: AccountInfo, + ): Promise { + return this.liquidationProgram.coder.accounts.decode( + "liquidation", + accountInfo.data, + ); + } + + async fetchRefundRecord( + refundRecord: PublicKey, + ): Promise { + return this.liquidationProgram.account.refundRecord.fetchNullable( + refundRecord, + ); + } + + async deserializeRefundRecord( + accountInfo: AccountInfo, + ): Promise { + return this.liquidationProgram.coder.accounts.decode( + "refundRecord", + accountInfo.data, + ); + } + + async getLiquidation({ + baseMint, + quoteMint, + createKey, + }: { + baseMint: PublicKey; + quoteMint: PublicKey; + createKey: PublicKey; + }): Promise { + const liquidation = this.getLiquidationAddress({ + baseMint, + quoteMint, + createKey, + }); + return this.fetchLiquidation(liquidation); + } + + initializeLiquidationIx({ + durationSeconds, + createKey, + recordAuthority, + liquidationAuthority, + baseMint, + quoteMint, + payer = this.provider.publicKey, + }: { + durationSeconds: number; + createKey: PublicKey; + recordAuthority: PublicKey; + liquidationAuthority: PublicKey; + baseMint: PublicKey; + quoteMint: PublicKey; + payer?: PublicKey; + }) { + const liquidation = this.getLiquidationAddress({ + baseMint, + quoteMint, + createKey, + }); + + const liquidationQuoteVault = getAssociatedTokenAddressSync( + quoteMint, + liquidation, + true, + ); + + return this.liquidationProgram.methods + .initializeLiquidation({ durationSeconds }) + .accounts({ + payer, + createKey, + recordAuthority, + liquidationAuthority, + baseMint, + quoteMint, + liquidation, + liquidationQuoteVault, + systemProgram: SystemProgram.programId, + tokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + }); + } + + setRefundRecordIx({ + baseAssigned, + quoteRefundable, + recordAuthority, + liquidation, + recipient, + payer = this.provider.publicKey, + }: { + baseAssigned: BN; + quoteRefundable: BN; + recordAuthority: PublicKey; + liquidation: PublicKey; + recipient: PublicKey; + payer?: PublicKey; + }) { + const refundRecord = this.getRefundRecordAddress({ + liquidation, + recipient, + }); + + return this.liquidationProgram.methods + .setRefundRecord({ baseAssigned, quoteRefundable }) + .accounts({ + payer, + recordAuthority, + liquidation, + recipient, + refundRecord, + systemProgram: SystemProgram.programId, + }); + } + + activateLiquidationIx({ + liquidationAuthority, + liquidation, + liquidationAuthorityQuoteAccount, + quoteMint, + }: { + liquidationAuthority: PublicKey; + liquidation: PublicKey; + liquidationAuthorityQuoteAccount?: PublicKey; + quoteMint: PublicKey; + }) { + const liquidationQuoteVault = getAssociatedTokenAddressSync( + quoteMint, + liquidation, + true, + ); + + const resolvedLiquidationAuthorityQuoteAccount = + liquidationAuthorityQuoteAccount ?? + getAssociatedTokenAddressSync(quoteMint, liquidationAuthority, true); + + return this.liquidationProgram.methods.activateLiquidation().accounts({ + liquidationAuthority, + liquidation, + liquidationAuthorityQuoteAccount: + resolvedLiquidationAuthorityQuoteAccount, + liquidationQuoteVault, + quoteMint, + tokenProgram: TOKEN_PROGRAM_ID, + }); + } + + refundIx({ + recipient, + liquidation, + baseMint, + recipientBaseAccount, + quoteMint, + }: { + recipient: PublicKey; + liquidation: PublicKey; + baseMint: PublicKey; + recipientBaseAccount?: PublicKey; + quoteMint: PublicKey; + }) { + const refundRecord = this.getRefundRecordAddress({ + liquidation, + recipient, + }); + + const resolvedRecipientBaseAccount = + recipientBaseAccount ?? + getAssociatedTokenAddressSync(baseMint, recipient, true); + + const liquidationQuoteVault = getAssociatedTokenAddressSync( + quoteMint, + liquidation, + true, + ); + + const recipientQuoteAccount = getAssociatedTokenAddressSync( + quoteMint, + recipient, + true, + ); + + return this.liquidationProgram.methods.refund().accounts({ + recipient, + liquidation, + refundRecord, + baseMint, + recipientBaseAccount: resolvedRecipientBaseAccount, + liquidationQuoteVault, + recipientQuoteAccount, + quoteMint, + tokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, + }); + } + + withdrawRemainingQuoteIx({ + liquidationAuthority, + liquidation, + liquidationAuthorityQuoteAccount, + quoteMint, + }: { + liquidationAuthority: PublicKey; + liquidation: PublicKey; + liquidationAuthorityQuoteAccount?: PublicKey; + quoteMint: PublicKey; + }) { + const liquidationQuoteVault = getAssociatedTokenAddressSync( + quoteMint, + liquidation, + true, + ); + + const resolvedLiquidationAuthorityQuoteAccount = + liquidationAuthorityQuoteAccount ?? + getAssociatedTokenAddressSync(quoteMint, liquidationAuthority, true); + + return this.liquidationProgram.methods.withdrawRemainingQuote().accounts({ + liquidationAuthority, + liquidation, + liquidationQuoteVault, + liquidationAuthorityQuoteAccount: + resolvedLiquidationAuthorityQuoteAccount, + quoteMint, + tokenProgram: TOKEN_PROGRAM_ID, + }); + } + + async getRefundRecord({ + liquidation, + recipient, + }: { + liquidation: PublicKey; + recipient: PublicKey; + }): Promise { + const refundRecord = this.getRefundRecordAddress({ + liquidation, + recipient, + }); + return this.fetchRefundRecord(refundRecord); + } + + public getLiquidationAddress({ + baseMint, + quoteMint, + createKey, + }: { + baseMint: PublicKey; + quoteMint: PublicKey; + createKey: PublicKey; + }): PublicKey { + return getLiquidationAddr({ baseMint, quoteMint, createKey })[0]; + } + + public getRefundRecordAddress({ + liquidation, + recipient, + }: { + liquidation: PublicKey; + recipient: PublicKey; + }): PublicKey { + return getRefundRecordAddr({ liquidation, recipient })[0]; + } + + public getEventAuthorityAddress(): PublicKey { + return getEventAuthorityAddr(this.programId)[0]; + } +} diff --git a/sdk/src/v0.7/constants.ts b/sdk/src/v0.7/constants.ts index 8b2c4b76b..bf3e2322d 100644 --- a/sdk/src/v0.7/constants.ts +++ b/sdk/src/v0.7/constants.ts @@ -29,6 +29,9 @@ export const MINT_GOVERNOR_PROGRAM_ID = new PublicKey( export const PERFORMANCE_PACKAGE_V2_PROGRAM_ID = new PublicKey( "pPV2pfrxnmstSb9j7kEeCLny5BGj6SNwCWGd6xbGGzz", ); +export const LIQUIDATION_PROGRAM_ID = new PublicKey( + "LiQnowFbFQdYyZhF4pUbpsrZCjxRTQ1upKJxZ2VXjde", +); export const MPL_TOKEN_METADATA_PROGRAM_ID = new PublicKey( "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s", diff --git a/sdk/src/v0.7/index.ts b/sdk/src/v0.7/index.ts index 1f5987bbc..b705e0721 100644 --- a/sdk/src/v0.7/index.ts +++ b/sdk/src/v0.7/index.ts @@ -8,3 +8,4 @@ export * from "./LaunchpadClient.js"; export * from "./PriceBasedPerformancePackageClient.js"; export * from "./MintGovernorClient.js"; export * from "./PerformancePackageV2Client.js"; +export * from "./LiquidationClient.js"; diff --git a/sdk/src/v0.7/types/index.ts b/sdk/src/v0.7/types/index.ts index 7a17781f8..62d73d863 100644 --- a/sdk/src/v0.7/types/index.ts +++ b/sdk/src/v0.7/types/index.ts @@ -37,6 +37,12 @@ import { } from "./performance_package_v2.js"; export { PerformancePackageV2Program, PerformancePackageV2IDL }; +import { + Liquidation as LiquidationProgram, + IDL as LiquidationIDL, +} from "./liquidation.js"; +export { LiquidationProgram, LiquidationIDL }; + export { LowercaseKeys } from "./utils.js"; import type { IdlAccounts, IdlTypes, IdlEvents } from "@coral-xyz/anchor"; @@ -91,6 +97,10 @@ export type PerformancePackageV2ProposerType = export type PerformancePackageV2ThresholdTranche = IdlTypes["ThresholdTranche"]; +export type LiquidationAccount = IdlAccounts["liquidation"]; +export type RefundRecordAccount = + IdlAccounts["refundRecord"]; + export type BidWallInitializedEvent = IdlEvents["BidWallInitializedEvent"]; export type BidWallTokensSoldEvent = diff --git a/sdk/src/v0.7/types/liquidation.ts b/sdk/src/v0.7/types/liquidation.ts new file mode 100644 index 000000000..a83a162cf --- /dev/null +++ b/sdk/src/v0.7/types/liquidation.ts @@ -0,0 +1,1515 @@ +export type Liquidation = { + version: "0.1.0"; + name: "liquidation"; + instructions: [ + { + name: "initializeLiquidation"; + accounts: [ + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "createKey"; + isMut: false; + isSigner: true; + }, + { + name: "recordAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "liquidationAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "baseMint"; + isMut: false; + isSigner: false; + }, + { + name: "quoteMint"; + isMut: false; + isSigner: false; + }, + { + name: "liquidation"; + isMut: true; + isSigner: false; + }, + { + name: "liquidationQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "associatedTokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "args"; + type: { + defined: "InitializeLiquidationArgs"; + }; + }, + ]; + }, + { + name: "setRefundRecord"; + accounts: [ + { + name: "payer"; + isMut: true; + isSigner: true; + }, + { + name: "recordAuthority"; + isMut: false; + isSigner: true; + }, + { + name: "liquidation"; + isMut: true; + isSigner: false; + }, + { + name: "recipient"; + isMut: false; + isSigner: false; + }, + { + name: "refundRecord"; + isMut: true; + isSigner: false; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: [ + { + name: "args"; + type: { + defined: "SetRefundRecordArgs"; + }; + }, + ]; + }, + { + name: "activateLiquidation"; + accounts: [ + { + name: "liquidationAuthority"; + isMut: false; + isSigner: true; + }, + { + name: "liquidation"; + isMut: true; + isSigner: false; + }, + { + name: "liquidationAuthorityQuoteAccount"; + isMut: true; + isSigner: false; + }, + { + name: "liquidationQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "quoteMint"; + isMut: false; + isSigner: false; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, + { + name: "refund"; + accounts: [ + { + name: "recipient"; + isMut: true; + isSigner: true; + }, + { + name: "liquidation"; + isMut: true; + isSigner: false; + }, + { + name: "refundRecord"; + isMut: true; + isSigner: false; + }, + { + name: "recipientBaseAccount"; + isMut: true; + isSigner: false; + }, + { + name: "liquidationQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "recipientQuoteAccount"; + isMut: true; + isSigner: false; + }, + { + name: "baseMint"; + isMut: true; + isSigner: false; + }, + { + name: "quoteMint"; + isMut: false; + isSigner: false; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "associatedTokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "systemProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, + { + name: "withdrawRemainingQuote"; + accounts: [ + { + name: "liquidationAuthority"; + isMut: false; + isSigner: true; + }, + { + name: "liquidation"; + isMut: true; + isSigner: false; + }, + { + name: "liquidationQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "liquidationAuthorityQuoteAccount"; + isMut: true; + isSigner: false; + }, + { + name: "quoteMint"; + isMut: false; + isSigner: false; + }, + { + name: "tokenProgram"; + isMut: false; + isSigner: false; + }, + { + name: "eventAuthority"; + isMut: false; + isSigner: false; + }, + { + name: "program"; + isMut: false; + isSigner: false; + }, + ]; + args: []; + }, + ]; + accounts: [ + { + name: "liquidation"; + type: { + kind: "struct"; + fields: [ + { + name: "createKey"; + docs: ["Arbitrary keypair used to make the PDA unique."]; + type: "publicKey"; + }, + { + name: "recordAuthority"; + docs: [ + "The address that can create and modify RefundRecords during setup.", + ]; + type: "publicKey"; + }, + { + name: "liquidationAuthority"; + docs: [ + "The address that activates the liquidation and receives remaining quote tokens post-deadline.", + ]; + type: "publicKey"; + }, + { + name: "baseMint"; + docs: ["The project token mint (tokens to be burned)."]; + type: "publicKey"; + }, + { + name: "quoteMint"; + docs: ["The refund token mint (USDC)."]; + type: "publicKey"; + }, + { + name: "totalQuoteRefundable"; + docs: ["Sum of all RefundRecord quote_refundable values."]; + type: "u64"; + }, + { + name: "totalQuoteRefunded"; + docs: [ + "Sum of all quote tokens actually transferred to users so far.", + ]; + type: "u64"; + }, + { + name: "totalBaseAssigned"; + docs: ["Sum of all RefundRecord base_assigned values."]; + type: "u64"; + }, + { + name: "totalBaseBurned"; + docs: ["Sum of all base tokens actually burned so far."]; + type: "u64"; + }, + { + name: "startedAt"; + docs: [ + "Unix timestamp when ActivateLiquidation was called. 0 before activation.", + ]; + type: "i64"; + }, + { + name: "durationSeconds"; + docs: ["How long the refund window lasts after activation."]; + type: "u32"; + }, + { + name: "seqNum"; + docs: ["Event sequence number for indexing."]; + type: "u64"; + }, + { + name: "isRefunding"; + docs: ["Whether refunds are currently enabled."]; + type: "bool"; + }, + { + name: "pdaBump"; + docs: ["PDA bump seed."]; + type: "u8"; + }, + ]; + }; + }, + { + name: "refundRecord"; + type: { + kind: "struct"; + fields: [ + { + name: "liquidation"; + docs: ["The parent Liquidation account."]; + type: "publicKey"; + }, + { + name: "recipient"; + docs: ["The user this record belongs to."]; + type: "publicKey"; + }, + { + name: "baseAssigned"; + docs: ["Total base tokens this user is eligible to burn."]; + type: "u64"; + }, + { + name: "baseBurned"; + docs: ["Base tokens this user has burned so far."]; + type: "u64"; + }, + { + name: "quoteRefundable"; + docs: [ + "Total quote tokens this user can receive if they burn all assigned base.", + ]; + type: "u64"; + }, + { + name: "quoteRefunded"; + docs: ["Quote tokens already transferred to this user."]; + type: "u64"; + }, + { + name: "pdaBump"; + docs: ["PDA bump seed."]; + type: "u8"; + }, + ]; + }; + }, + ]; + types: [ + { + name: "CommonFields"; + type: { + kind: "struct"; + fields: [ + { + name: "slot"; + type: "u64"; + }, + { + name: "unixTimestamp"; + type: "i64"; + }, + { + name: "liquidationSeqNum"; + type: "u64"; + }, + ]; + }; + }, + { + name: "InitializeLiquidationArgs"; + type: { + kind: "struct"; + fields: [ + { + name: "durationSeconds"; + type: "u32"; + }, + ]; + }; + }, + { + name: "SetRefundRecordArgs"; + type: { + kind: "struct"; + fields: [ + { + name: "baseAssigned"; + type: "u64"; + }, + { + name: "quoteRefundable"; + type: "u64"; + }, + ]; + }; + }, + ]; + events: [ + { + name: "LiquidationCreatedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "liquidation"; + type: "publicKey"; + index: false; + }, + { + name: "createKey"; + type: "publicKey"; + index: false; + }, + { + name: "recordAuthority"; + type: "publicKey"; + index: false; + }, + { + name: "liquidationAuthority"; + type: "publicKey"; + index: false; + }, + { + name: "baseMint"; + type: "publicKey"; + index: false; + }, + { + name: "quoteMint"; + type: "publicKey"; + index: false; + }, + { + name: "durationSeconds"; + type: "u32"; + index: false; + }, + { + name: "pdaBump"; + type: "u8"; + index: false; + }, + ]; + }, + { + name: "LiquidationActivatedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "liquidation"; + type: "publicKey"; + index: false; + }, + { + name: "totalQuoteFunded"; + type: "u64"; + index: false; + }, + { + name: "startedAt"; + type: "i64"; + index: false; + }, + ]; + }, + { + name: "RefundRecordSetEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "liquidation"; + type: "publicKey"; + index: false; + }, + { + name: "refundRecord"; + type: "publicKey"; + index: false; + }, + { + name: "recipient"; + type: "publicKey"; + index: false; + }, + { + name: "baseAssigned"; + type: "u64"; + index: false; + }, + { + name: "quoteRefundable"; + type: "u64"; + index: false; + }, + { + name: "liquidationTotalBaseAssigned"; + type: "u64"; + index: false; + }, + { + name: "liquidationTotalQuoteRefundable"; + type: "u64"; + index: false; + }, + { + name: "pdaBump"; + type: "u8"; + index: false; + }, + ]; + }, + { + name: "RefundEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "liquidation"; + type: "publicKey"; + index: false; + }, + { + name: "refundRecord"; + type: "publicKey"; + index: false; + }, + { + name: "recipient"; + type: "publicKey"; + index: false; + }, + { + name: "baseBurned"; + type: "u64"; + index: false; + }, + { + name: "quoteRefunded"; + type: "u64"; + index: false; + }, + { + name: "postRecordBaseBurned"; + type: "u64"; + index: false; + }, + { + name: "postRecordQuoteRefunded"; + type: "u64"; + index: false; + }, + { + name: "postLiquidationTotalBaseBurned"; + type: "u64"; + index: false; + }, + { + name: "postLiquidationTotalQuoteRefunded"; + type: "u64"; + index: false; + }, + ]; + }, + { + name: "WithdrawRemainingQuoteEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "liquidation"; + type: "publicKey"; + index: false; + }, + { + name: "liquidationAuthority"; + type: "publicKey"; + index: false; + }, + { + name: "amount"; + type: "u64"; + index: false; + }, + ]; + }, + ]; + errors: [ + { + code: 6000; + name: "RefundingNotEnabled"; + msg: "Refunding is not enabled"; + }, + { + code: 6001; + name: "AlreadyActivated"; + msg: "Liquidation is already activated"; + }, + { + code: 6002; + name: "NothingToFund"; + msg: "No quote tokens to fund"; + }, + { + code: 6003; + name: "NoBaseAssigned"; + msg: "No base tokens assigned"; + }, + { + code: 6004; + name: "RefundWindowExpired"; + msg: "Refund window has expired"; + }, + { + code: 6005; + name: "RefundWindowNotExpired"; + msg: "Refund window has not expired"; + }, + { + code: 6006; + name: "InvalidDuration"; + msg: "Duration must be greater than zero"; + }, + { + code: 6007; + name: "NothingToRefund"; + msg: "Nothing to refund"; + }, + { + code: 6008; + name: "InvalidAllocation"; + msg: "Invalid allocation"; + }, + { + code: 6009; + name: "InvalidAuthority"; + msg: "Invalid authority"; + }, + { + code: 6010; + name: "InvalidMint"; + msg: "Invalid mint"; + }, + ]; +}; + +export const IDL: Liquidation = { + version: "0.1.0", + name: "liquidation", + instructions: [ + { + name: "initializeLiquidation", + accounts: [ + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "createKey", + isMut: false, + isSigner: true, + }, + { + name: "recordAuthority", + isMut: false, + isSigner: false, + }, + { + name: "liquidationAuthority", + isMut: false, + isSigner: false, + }, + { + name: "baseMint", + isMut: false, + isSigner: false, + }, + { + name: "quoteMint", + isMut: false, + isSigner: false, + }, + { + name: "liquidation", + isMut: true, + isSigner: false, + }, + { + name: "liquidationQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "associatedTokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: "args", + type: { + defined: "InitializeLiquidationArgs", + }, + }, + ], + }, + { + name: "setRefundRecord", + accounts: [ + { + name: "payer", + isMut: true, + isSigner: true, + }, + { + name: "recordAuthority", + isMut: false, + isSigner: true, + }, + { + name: "liquidation", + isMut: true, + isSigner: false, + }, + { + name: "recipient", + isMut: false, + isSigner: false, + }, + { + name: "refundRecord", + isMut: true, + isSigner: false, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: "args", + type: { + defined: "SetRefundRecordArgs", + }, + }, + ], + }, + { + name: "activateLiquidation", + accounts: [ + { + name: "liquidationAuthority", + isMut: false, + isSigner: true, + }, + { + name: "liquidation", + isMut: true, + isSigner: false, + }, + { + name: "liquidationAuthorityQuoteAccount", + isMut: true, + isSigner: false, + }, + { + name: "liquidationQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "quoteMint", + isMut: false, + isSigner: false, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: "refund", + accounts: [ + { + name: "recipient", + isMut: true, + isSigner: true, + }, + { + name: "liquidation", + isMut: true, + isSigner: false, + }, + { + name: "refundRecord", + isMut: true, + isSigner: false, + }, + { + name: "recipientBaseAccount", + isMut: true, + isSigner: false, + }, + { + name: "liquidationQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "recipientQuoteAccount", + isMut: true, + isSigner: false, + }, + { + name: "baseMint", + isMut: true, + isSigner: false, + }, + { + name: "quoteMint", + isMut: false, + isSigner: false, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "associatedTokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "systemProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: "withdrawRemainingQuote", + accounts: [ + { + name: "liquidationAuthority", + isMut: false, + isSigner: true, + }, + { + name: "liquidation", + isMut: true, + isSigner: false, + }, + { + name: "liquidationQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "liquidationAuthorityQuoteAccount", + isMut: true, + isSigner: false, + }, + { + name: "quoteMint", + isMut: false, + isSigner: false, + }, + { + name: "tokenProgram", + isMut: false, + isSigner: false, + }, + { + name: "eventAuthority", + isMut: false, + isSigner: false, + }, + { + name: "program", + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + ], + accounts: [ + { + name: "liquidation", + type: { + kind: "struct", + fields: [ + { + name: "createKey", + docs: ["Arbitrary keypair used to make the PDA unique."], + type: "publicKey", + }, + { + name: "recordAuthority", + docs: [ + "The address that can create and modify RefundRecords during setup.", + ], + type: "publicKey", + }, + { + name: "liquidationAuthority", + docs: [ + "The address that activates the liquidation and receives remaining quote tokens post-deadline.", + ], + type: "publicKey", + }, + { + name: "baseMint", + docs: ["The project token mint (tokens to be burned)."], + type: "publicKey", + }, + { + name: "quoteMint", + docs: ["The refund token mint (USDC)."], + type: "publicKey", + }, + { + name: "totalQuoteRefundable", + docs: ["Sum of all RefundRecord quote_refundable values."], + type: "u64", + }, + { + name: "totalQuoteRefunded", + docs: [ + "Sum of all quote tokens actually transferred to users so far.", + ], + type: "u64", + }, + { + name: "totalBaseAssigned", + docs: ["Sum of all RefundRecord base_assigned values."], + type: "u64", + }, + { + name: "totalBaseBurned", + docs: ["Sum of all base tokens actually burned so far."], + type: "u64", + }, + { + name: "startedAt", + docs: [ + "Unix timestamp when ActivateLiquidation was called. 0 before activation.", + ], + type: "i64", + }, + { + name: "durationSeconds", + docs: ["How long the refund window lasts after activation."], + type: "u32", + }, + { + name: "seqNum", + docs: ["Event sequence number for indexing."], + type: "u64", + }, + { + name: "isRefunding", + docs: ["Whether refunds are currently enabled."], + type: "bool", + }, + { + name: "pdaBump", + docs: ["PDA bump seed."], + type: "u8", + }, + ], + }, + }, + { + name: "refundRecord", + type: { + kind: "struct", + fields: [ + { + name: "liquidation", + docs: ["The parent Liquidation account."], + type: "publicKey", + }, + { + name: "recipient", + docs: ["The user this record belongs to."], + type: "publicKey", + }, + { + name: "baseAssigned", + docs: ["Total base tokens this user is eligible to burn."], + type: "u64", + }, + { + name: "baseBurned", + docs: ["Base tokens this user has burned so far."], + type: "u64", + }, + { + name: "quoteRefundable", + docs: [ + "Total quote tokens this user can receive if they burn all assigned base.", + ], + type: "u64", + }, + { + name: "quoteRefunded", + docs: ["Quote tokens already transferred to this user."], + type: "u64", + }, + { + name: "pdaBump", + docs: ["PDA bump seed."], + type: "u8", + }, + ], + }, + }, + ], + types: [ + { + name: "CommonFields", + type: { + kind: "struct", + fields: [ + { + name: "slot", + type: "u64", + }, + { + name: "unixTimestamp", + type: "i64", + }, + { + name: "liquidationSeqNum", + type: "u64", + }, + ], + }, + }, + { + name: "InitializeLiquidationArgs", + type: { + kind: "struct", + fields: [ + { + name: "durationSeconds", + type: "u32", + }, + ], + }, + }, + { + name: "SetRefundRecordArgs", + type: { + kind: "struct", + fields: [ + { + name: "baseAssigned", + type: "u64", + }, + { + name: "quoteRefundable", + type: "u64", + }, + ], + }, + }, + ], + events: [ + { + name: "LiquidationCreatedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "liquidation", + type: "publicKey", + index: false, + }, + { + name: "createKey", + type: "publicKey", + index: false, + }, + { + name: "recordAuthority", + type: "publicKey", + index: false, + }, + { + name: "liquidationAuthority", + type: "publicKey", + index: false, + }, + { + name: "baseMint", + type: "publicKey", + index: false, + }, + { + name: "quoteMint", + type: "publicKey", + index: false, + }, + { + name: "durationSeconds", + type: "u32", + index: false, + }, + { + name: "pdaBump", + type: "u8", + index: false, + }, + ], + }, + { + name: "LiquidationActivatedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "liquidation", + type: "publicKey", + index: false, + }, + { + name: "totalQuoteFunded", + type: "u64", + index: false, + }, + { + name: "startedAt", + type: "i64", + index: false, + }, + ], + }, + { + name: "RefundRecordSetEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "liquidation", + type: "publicKey", + index: false, + }, + { + name: "refundRecord", + type: "publicKey", + index: false, + }, + { + name: "recipient", + type: "publicKey", + index: false, + }, + { + name: "baseAssigned", + type: "u64", + index: false, + }, + { + name: "quoteRefundable", + type: "u64", + index: false, + }, + { + name: "liquidationTotalBaseAssigned", + type: "u64", + index: false, + }, + { + name: "liquidationTotalQuoteRefundable", + type: "u64", + index: false, + }, + { + name: "pdaBump", + type: "u8", + index: false, + }, + ], + }, + { + name: "RefundEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "liquidation", + type: "publicKey", + index: false, + }, + { + name: "refundRecord", + type: "publicKey", + index: false, + }, + { + name: "recipient", + type: "publicKey", + index: false, + }, + { + name: "baseBurned", + type: "u64", + index: false, + }, + { + name: "quoteRefunded", + type: "u64", + index: false, + }, + { + name: "postRecordBaseBurned", + type: "u64", + index: false, + }, + { + name: "postRecordQuoteRefunded", + type: "u64", + index: false, + }, + { + name: "postLiquidationTotalBaseBurned", + type: "u64", + index: false, + }, + { + name: "postLiquidationTotalQuoteRefunded", + type: "u64", + index: false, + }, + ], + }, + { + name: "WithdrawRemainingQuoteEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "liquidation", + type: "publicKey", + index: false, + }, + { + name: "liquidationAuthority", + type: "publicKey", + index: false, + }, + { + name: "amount", + type: "u64", + index: false, + }, + ], + }, + ], + errors: [ + { + code: 6000, + name: "RefundingNotEnabled", + msg: "Refunding is not enabled", + }, + { + code: 6001, + name: "AlreadyActivated", + msg: "Liquidation is already activated", + }, + { + code: 6002, + name: "NothingToFund", + msg: "No quote tokens to fund", + }, + { + code: 6003, + name: "NoBaseAssigned", + msg: "No base tokens assigned", + }, + { + code: 6004, + name: "RefundWindowExpired", + msg: "Refund window has expired", + }, + { + code: 6005, + name: "RefundWindowNotExpired", + msg: "Refund window has not expired", + }, + { + code: 6006, + name: "InvalidDuration", + msg: "Duration must be greater than zero", + }, + { + code: 6007, + name: "NothingToRefund", + msg: "Nothing to refund", + }, + { + code: 6008, + name: "InvalidAllocation", + msg: "Invalid allocation", + }, + { + code: 6009, + name: "InvalidAuthority", + msg: "Invalid authority", + }, + { + code: 6010, + name: "InvalidMint", + msg: "Invalid mint", + }, + ], +}; diff --git a/sdk/src/v0.7/utils/pda.ts b/sdk/src/v0.7/utils/pda.ts index 9280f6a65..24f746632 100644 --- a/sdk/src/v0.7/utils/pda.ts +++ b/sdk/src/v0.7/utils/pda.ts @@ -20,6 +20,7 @@ import { FUTARCHY_PROGRAM_ID, BID_WALL_PROGRAM_ID, MINT_GOVERNOR_PROGRAM_ID, + LIQUIDATION_PROGRAM_ID, } from "../constants.js"; export const getEventAuthorityAddr = (programId: PublicKey) => { @@ -331,3 +332,44 @@ export const getChangeRequestV2Addr = ({ programId, ); }; + +export const getLiquidationAddr = ({ + programId = LIQUIDATION_PROGRAM_ID, + baseMint, + quoteMint, + createKey, +}: { + programId?: PublicKey; + baseMint: PublicKey; + quoteMint: PublicKey; + createKey: PublicKey; +}) => { + return PublicKey.findProgramAddressSync( + [ + Buffer.from("liquidation"), + baseMint.toBuffer(), + quoteMint.toBuffer(), + createKey.toBuffer(), + ], + programId, + ); +}; + +export const getRefundRecordAddr = ({ + programId = LIQUIDATION_PROGRAM_ID, + liquidation, + recipient, +}: { + programId?: PublicKey; + liquidation: PublicKey; + recipient: PublicKey; +}) => { + return PublicKey.findProgramAddressSync( + [ + Buffer.from("refund_record"), + liquidation.toBuffer(), + recipient.toBuffer(), + ], + programId, + ); +}; diff --git a/tests/liquidation/main.test.ts b/tests/liquidation/main.test.ts new file mode 100644 index 000000000..a0dabe85a --- /dev/null +++ b/tests/liquidation/main.test.ts @@ -0,0 +1,22 @@ +import { LiquidationClient } from "@metadaoproject/futarchy/v0.7"; +import { BankrunProvider } from "anchor-bankrun"; +import initializeLiquidation from "./unit/initializeLiquidation.test.js"; +import setRefundRecord from "./unit/setRefundRecord.test.js"; +import activateLiquidation from "./unit/activateLiquidation.test.js"; +import refund from "./unit/refund.test.js"; +import withdrawRemainingQuote from "./unit/withdrawRemainingQuote.test.js"; + +export default function suite() { + before(async function () { + const provider = new BankrunProvider(this.context); + this.liquidation = LiquidationClient.createClient({ + provider: provider as any, + }); + }); + + describe("#initialize_liquidation", initializeLiquidation); + describe("#set_refund_record", setRefundRecord); + describe("#activate_liquidation", activateLiquidation); + describe("#refund", refund); + describe("#withdraw_remaining_quote", withdrawRemainingQuote); +} diff --git a/tests/liquidation/unit/activateLiquidation.test.ts b/tests/liquidation/unit/activateLiquidation.test.ts new file mode 100644 index 000000000..2f8071ac5 --- /dev/null +++ b/tests/liquidation/unit/activateLiquidation.test.ts @@ -0,0 +1,233 @@ +import { LiquidationClient } from "@metadaoproject/futarchy/v0.7"; +import { Keypair, PublicKey, ComputeBudgetProgram } from "@solana/web3.js"; +import { assert } from "chai"; +import { expectError } from "../../utils.js"; +import { setupLiquidationWithRefundRecords } from "../utils.js"; +import BN from "bn.js"; +import * as token from "@solana/spl-token"; + +export default function suite() { + let liquidationClient: LiquidationClient; + let baseMint: PublicKey; + let quoteMint: PublicKey; + let createKey: Keypair; + let recordAuthority: Keypair; + let liquidationAuthority: Keypair; + let liquidation: PublicKey; + + before(async function () { + liquidationClient = this.liquidation; + }); + + beforeEach(async function () { + const recipient1 = Keypair.generate(); + const recipient2 = Keypair.generate(); + + const result = await setupLiquidationWithRefundRecords(this, [ + { + recipient: recipient1, + baseAssigned: new BN(1_000_000_000), + quoteRefundable: new BN(500_000_000), + }, + { + recipient: recipient2, + baseAssigned: new BN(2_000_000_000), + quoteRefundable: new BN(1_000_000_000), + }, + ]); + + baseMint = result.baseMint; + quoteMint = result.quoteMint; + createKey = result.createKey; + recordAuthority = result.recordAuthority; + liquidationAuthority = result.liquidationAuthority; + liquidation = result.liquidation; + + // Fund the liquidation authority with enough quote tokens + await this.mintTo( + quoteMint, + liquidationAuthority.publicKey, + this.payer, + 1_500_000_000, // 500 + 1000 + ); + }); + + it("successfully activates liquidation", async function () { + await liquidationClient + .activateLiquidationIx({ + liquidationAuthority: liquidationAuthority.publicKey, + liquidation, + quoteMint, + }) + .signers([liquidationAuthority]) + .rpc(); + + const liq = await liquidationClient.fetchLiquidation(liquidation); + assert.equal(liq.isRefunding, true); + assert.ok(liq.startedAt.toNumber() > 0); + + // Vault should have the total quote refundable + const vaultBalance = await this.getTokenBalance(quoteMint, liquidation); + assert.equal(vaultBalance.toString(), "1500000000"); + + // Authority's quote account should be drained + const authorityBalance = await this.getTokenBalance( + quoteMint, + liquidationAuthority.publicKey, + ); + assert.equal(authorityBalance.toString(), "0"); + }); + + it("throws error when liquidation_authority does not match", async function () { + const wrongAuthority = Keypair.generate(); + + await this.mintTo( + quoteMint, + wrongAuthority.publicKey, + this.payer, + 1_500_000_000, + ); + + const callbacks = expectError( + "InvalidAuthority", + "Should have thrown InvalidAuthority error", + ); + + await liquidationClient + .activateLiquidationIx({ + liquidationAuthority: wrongAuthority.publicKey, + liquidation, + quoteMint, + }) + .signers([wrongAuthority]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("throws error when already activated", async function () { + await liquidationClient + .activateLiquidationIx({ + liquidationAuthority: liquidationAuthority.publicKey, + liquidation, + quoteMint, + }) + .signers([liquidationAuthority]) + .rpc(); + + // Try to activate again + const callbacks = expectError( + "AlreadyActivated", + "Should have thrown AlreadyActivated error", + ); + + await liquidationClient + .activateLiquidationIx({ + liquidationAuthority: liquidationAuthority.publicKey, + liquidation, + quoteMint, + }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) + .signers([liquidationAuthority]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("throws error when total_base_assigned is zero", async function () { + const recipient = Keypair.generate(); + + const result = await setupLiquidationWithRefundRecords(this, [ + { + recipient, + baseAssigned: new BN(0), + quoteRefundable: new BN(0), + }, + ]); + + await this.createTokenAccount( + result.quoteMint, + result.liquidationAuthority.publicKey, + ); + + const callbacks = expectError( + "NoBaseAssigned", + "Should have thrown NoBaseAssigned error", + ); + + await liquidationClient + .activateLiquidationIx({ + liquidationAuthority: result.liquidationAuthority.publicKey, + liquidation: result.liquidation, + quoteMint: result.quoteMint, + }) + .signers([result.liquidationAuthority]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("throws error when total_quote_refundable is zero", async function () { + const recipient = Keypair.generate(); + + const result = await setupLiquidationWithRefundRecords(this, [ + { + recipient, + baseAssigned: new BN(1_000_000_000), + quoteRefundable: new BN(0), + }, + ]); + + await this.createTokenAccount( + result.quoteMint, + result.liquidationAuthority.publicKey, + ); + + const callbacks = expectError( + "NothingToFund", + "Should have thrown NothingToFund error", + ); + + await liquidationClient + .activateLiquidationIx({ + liquidationAuthority: result.liquidationAuthority.publicKey, + liquidation: result.liquidation, + quoteMint: result.quoteMint, + }) + .signers([result.liquidationAuthority]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("throws error when funder has insufficient quote tokens", async function () { + // Drain authority's tokens so they don't have enough + // Manipulate the token account to have only 100 tokens + const ataAddress = token.getAssociatedTokenAddressSync( + quoteMint, + liquidationAuthority.publicKey, + ); + const ataInfo = await this.banksClient.getAccount(ataAddress); + const ataData = Buffer.from(ataInfo.data); + // Token account amount is at offset 64 (after mint 32 + owner 32) + ataData.writeBigUInt64LE(BigInt(100_000_000), 64); + this.context.setAccount(ataAddress, { + data: ataData, + executable: false, + owner: token.TOKEN_PROGRAM_ID, + lamports: ataInfo.lamports, + }); + + try { + await liquidationClient + .activateLiquidationIx({ + liquidationAuthority: liquidationAuthority.publicKey, + liquidation, + quoteMint, + }) + .signers([liquidationAuthority]) + .rpc(); + assert.fail("Should have thrown an error for insufficient funds"); + } catch (e) { + assert.ok(e); + } + }); +} diff --git a/tests/liquidation/unit/initializeLiquidation.test.ts b/tests/liquidation/unit/initializeLiquidation.test.ts new file mode 100644 index 000000000..5872f809b --- /dev/null +++ b/tests/liquidation/unit/initializeLiquidation.test.ts @@ -0,0 +1,117 @@ +import { LiquidationClient } from "@metadaoproject/futarchy/v0.7"; +import { Keypair } from "@solana/web3.js"; +import { assert } from "chai"; +import * as token from "@solana/spl-token"; +import { expectError } from "../../utils.js"; +import { setupLiquidation } from "../utils.js"; + +export default function suite() { + let liquidationClient: LiquidationClient; + + before(async function () { + liquidationClient = this.liquidation; + }); + + it("successfully initializes a liquidation", async function () { + const { + baseMint, + quoteMint, + createKey, + recordAuthority, + liquidationAuthority, + liquidation, + } = await setupLiquidation(this); + + const durationSeconds = 86400; // 1 day + + await liquidationClient + .initializeLiquidationIx({ + durationSeconds, + createKey: createKey.publicKey, + recordAuthority: recordAuthority.publicKey, + liquidationAuthority: liquidationAuthority.publicKey, + baseMint, + quoteMint, + }) + .signers([createKey]) + .rpc(); + + const stored = await liquidationClient.fetchLiquidation(liquidation); + + assert.ok(stored.createKey.equals(createKey.publicKey)); + assert.ok(stored.recordAuthority.equals(recordAuthority.publicKey)); + assert.ok( + stored.liquidationAuthority.equals(liquidationAuthority.publicKey), + ); + assert.ok(stored.baseMint.equals(baseMint)); + assert.ok(stored.quoteMint.equals(quoteMint)); + assert.equal(stored.totalQuoteRefundable.toString(), "0"); + assert.equal(stored.totalQuoteRefunded.toString(), "0"); + assert.equal(stored.totalBaseAssigned.toString(), "0"); + assert.equal(stored.totalBaseBurned.toString(), "0"); + assert.equal(stored.startedAt.toString(), "0"); + assert.equal(stored.durationSeconds, durationSeconds); + assert.equal(stored.seqNum.toString(), "0"); + assert.equal(stored.isRefunding, false); + + // Verify quote vault ATA was created + const vaultAddress = token.getAssociatedTokenAddressSync( + quoteMint, + liquidation, + true, + ); + const vaultBalance = await this.getTokenBalance(quoteMint, liquidation); + assert.equal(vaultBalance.toString(), "0"); + }); + + it("throws error when base_mint and quote_mint are the same", async function () { + const { baseMint, createKey, recordAuthority, liquidationAuthority } = + await setupLiquidation(this); + + const callbacks = expectError( + "InvalidMint", + "Should have thrown InvalidMint error", + ); + + await liquidationClient + .initializeLiquidationIx({ + durationSeconds: 86400, + createKey: createKey.publicKey, + recordAuthority: recordAuthority.publicKey, + liquidationAuthority: liquidationAuthority.publicKey, + baseMint, + quoteMint: baseMint, + }) + .signers([createKey]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("throws error when duration_seconds is zero", async function () { + const { + baseMint, + quoteMint, + createKey, + recordAuthority, + liquidationAuthority, + } = await setupLiquidation(this); + + const callbacks = expectError( + "InvalidDuration", + "Should have thrown InvalidDuration error", + ); + + await liquidationClient + .initializeLiquidationIx({ + durationSeconds: 0, + createKey: createKey.publicKey, + recordAuthority: recordAuthority.publicKey, + liquidationAuthority: liquidationAuthority.publicKey, + baseMint, + quoteMint, + }) + .signers([createKey]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); +} diff --git a/tests/liquidation/unit/refund.test.ts b/tests/liquidation/unit/refund.test.ts new file mode 100644 index 000000000..0efc3f238 --- /dev/null +++ b/tests/liquidation/unit/refund.test.ts @@ -0,0 +1,419 @@ +import { LiquidationClient } from "@metadaoproject/futarchy/v0.7"; +import { + Keypair, + PublicKey, + ComputeBudgetProgram, + SystemProgram, +} from "@solana/web3.js"; +import { assert } from "chai"; +import { expectError } from "../../utils.js"; +import { + setupActivatedLiquidation, + setupLiquidationWithRefundRecords, +} from "../utils.js"; +import BN from "bn.js"; + +export default function suite() { + let liquidationClient: LiquidationClient; + let baseMint: PublicKey; + let quoteMint: PublicKey; + let baseMintAuthority: Keypair; + let liquidation: PublicKey; + let recipient: Keypair; + + before(async function () { + liquidationClient = this.liquidation; + }); + + beforeEach(async function () { + recipient = Keypair.generate(); + + const result = await setupActivatedLiquidation(this, [ + { + recipient, + baseAssigned: new BN(1_000_000_000), + quoteRefundable: new BN(500_000_000), + }, + ]); + + baseMint = result.baseMint; + quoteMint = result.quoteMint; + baseMintAuthority = result.baseMintAuthority; + liquidation = result.liquidation; + }); + + it("successfully burns base and receives proportional quote", async function () { + // Mint full 1000 tokens (matches assigned amount) + await this.mintTo( + baseMint, + recipient.publicKey, + baseMintAuthority, + 1_000_000_000, + ); + + await liquidationClient + .refundIx({ + recipient: recipient.publicKey, + liquidation, + baseMint, + quoteMint, + }) + .signers([recipient]) + .rpc(); + + const record = await liquidationClient.getRefundRecord({ + liquidation, + recipient: recipient.publicKey, + }); + assert.equal(record.baseBurned.toString(), "1000000000"); + assert.equal(record.quoteRefunded.toString(), "500000000"); + + const liq = await liquidationClient.fetchLiquidation(liquidation); + assert.equal(liq.totalBaseBurned.toString(), "1000000000"); + assert.equal(liq.totalQuoteRefunded.toString(), "500000000"); + + const quoteBalance = await this.getTokenBalance( + quoteMint, + recipient.publicKey, + ); + assert.equal(quoteBalance.toString(), "500000000"); + + const baseBalance = await this.getTokenBalance( + baseMint, + recipient.publicKey, + ); + assert.equal(baseBalance.toString(), "0"); + }); + + it("handles partial burn (less balance than assigned)", async function () { + // Mint only 400 tokens (less than 1000 assigned) + await this.mintTo( + baseMint, + recipient.publicKey, + baseMintAuthority, + 400_000_000, + ); + + await liquidationClient + .refundIx({ + recipient: recipient.publicKey, + liquidation, + baseMint, + quoteMint, + }) + .signers([recipient]) + .rpc(); + + // Burns 400, gets 500 * 400 / 1000 = 200 + const record = await liquidationClient.getRefundRecord({ + liquidation, + recipient: recipient.publicKey, + }); + assert.equal(record.baseBurned.toString(), "400000000"); + assert.equal(record.quoteRefunded.toString(), "200000000"); + + const quoteBalance = await this.getTokenBalance( + quoteMint, + recipient.publicKey, + ); + assert.equal(quoteBalance.toString(), "200000000"); + }); + + it("handles multiple refund calls by same user", async function () { + // Mint 500 tokens (half of assigned) + await this.mintTo( + baseMint, + recipient.publicKey, + baseMintAuthority, + 500_000_000, + ); + + // First refund: burns 500, gets 250 + await liquidationClient + .refundIx({ + recipient: recipient.publicKey, + liquidation, + baseMint, + quoteMint, + }) + .signers([recipient]) + .rpc(); + + let record = await liquidationClient.getRefundRecord({ + liquidation, + recipient: recipient.publicKey, + }); + assert.equal(record.baseBurned.toString(), "500000000"); + assert.equal(record.quoteRefunded.toString(), "250000000"); + + // Mint 500 more tokens + await this.mintTo( + baseMint, + recipient.publicKey, + baseMintAuthority, + 500_000_000, + ); + + // Second refund: burns remaining 500, gets 250 more + await liquidationClient + .refundIx({ + recipient: recipient.publicKey, + liquidation, + baseMint, + quoteMint, + }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) + .signers([recipient]) + .rpc(); + + record = await liquidationClient.getRefundRecord({ + liquidation, + recipient: recipient.publicKey, + }); + assert.equal(record.baseBurned.toString(), "1000000000"); + assert.equal(record.quoteRefunded.toString(), "500000000"); + + const quoteBalance = await this.getTokenBalance( + quoteMint, + recipient.publicKey, + ); + assert.equal(quoteBalance.toString(), "500000000"); + }); + + it("throws error when refunding is not enabled", async function () { + const nonActivatedRecipient = Keypair.generate(); + + // Setup liquidation WITHOUT activating + const result = await setupLiquidationWithRefundRecords(this, [ + { + recipient: nonActivatedRecipient, + baseAssigned: new BN(1_000_000_000), + quoteRefundable: new BN(500_000_000), + }, + ]); + + // Fund recipient with SOL for rent + this.context.setAccount(nonActivatedRecipient.publicKey, { + data: Buffer.alloc(0), + executable: false, + owner: SystemProgram.programId, + lamports: 1_000_000_000, + }); + + await this.mintTo( + result.baseMint, + nonActivatedRecipient.publicKey, + result.baseMintAuthority, + 1_000_000_000, + ); + + const callbacks = expectError( + "RefundingNotEnabled", + "Should have thrown RefundingNotEnabled error", + ); + + await liquidationClient + .refundIx({ + recipient: nonActivatedRecipient.publicKey, + liquidation: result.liquidation, + baseMint: result.baseMint, + quoteMint: result.quoteMint, + }) + .signers([nonActivatedRecipient]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("throws error when refund window has expired", async function () { + const expiredRecipient = Keypair.generate(); + + const result = await setupActivatedLiquidation( + this, + [ + { + recipient: expiredRecipient, + baseAssigned: new BN(1_000_000_000), + quoteRefundable: new BN(500_000_000), + }, + ], + { durationSeconds: 100 }, + ); + + await this.mintTo( + result.baseMint, + expiredRecipient.publicKey, + result.baseMintAuthority, + 1_000_000_000, + ); + + // Advance past the deadline + await this.advanceBySeconds(101); + + const callbacks = expectError( + "RefundWindowExpired", + "Should have thrown RefundWindowExpired error", + ); + + await liquidationClient + .refundIx({ + recipient: expiredRecipient.publicKey, + liquidation: result.liquidation, + baseMint: result.baseMint, + quoteMint: result.quoteMint, + }) + .signers([expiredRecipient]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("throws error when effective_burn is zero (fully burned)", async function () { + // Mint full 1000 tokens + await this.mintTo( + baseMint, + recipient.publicKey, + baseMintAuthority, + 1_000_000_000, + ); + + // First refund succeeds — burns all + await liquidationClient + .refundIx({ + recipient: recipient.publicKey, + liquidation, + baseMint, + quoteMint, + }) + .signers([recipient]) + .rpc(); + + // Second refund fails — nothing left to burn + const callbacks = expectError( + "NothingToRefund", + "Should have thrown NothingToRefund error", + ); + + await liquidationClient + .refundIx({ + recipient: recipient.publicKey, + liquidation, + baseMint, + quoteMint, + }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) + .signers([recipient]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("throws error when effective_burn is zero (zero balance)", async function () { + // Create base token account with zero balance (no minting) + await this.createTokenAccount(baseMint, recipient.publicKey); + + const callbacks = expectError( + "NothingToRefund", + "Should have thrown NothingToRefund error", + ); + + await liquidationClient + .refundIx({ + recipient: recipient.publicKey, + liquidation, + baseMint, + quoteMint, + }) + .signers([recipient]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("throws error when recipient does not match refund record", async function () { + const wrongRecipient = Keypair.generate(); + + // Fund wrong recipient with SOL and base tokens + this.context.setAccount(wrongRecipient.publicKey, { + data: Buffer.alloc(0), + executable: false, + owner: SystemProgram.programId, + lamports: 1_000_000_000, + }); + + await this.mintTo( + baseMint, + wrongRecipient.publicKey, + baseMintAuthority, + 1_000_000_000, + ); + + // Get the real recipient's refund record address to pass as override + const realRefundRecord = liquidationClient.getRefundRecordAddress({ + liquidation, + recipient: recipient.publicKey, + }); + + const callbacks = expectError( + "InvalidAuthority", + "Should have thrown InvalidAuthority error", + ); + + await liquidationClient + .refundIx({ + recipient: wrongRecipient.publicKey, + liquidation, + baseMint, + quoteMint, + }) + .accounts({ + refundRecord: realRefundRecord, + }) + .signers([wrongRecipient]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("refund right at deadline boundary succeeds", async function () { + const boundaryRecipient = Keypair.generate(); + + const result = await setupActivatedLiquidation( + this, + [ + { + recipient: boundaryRecipient, + baseAssigned: new BN(1_000_000_000), + quoteRefundable: new BN(500_000_000), + }, + ], + { durationSeconds: 100 }, + ); + + await this.mintTo( + result.baseMint, + boundaryRecipient.publicKey, + result.baseMintAuthority, + 1_000_000_000, + ); + + // Advance to exactly the deadline (started_at + 100) + await this.advanceBySeconds(100); + + await liquidationClient + .refundIx({ + recipient: boundaryRecipient.publicKey, + liquidation: result.liquidation, + baseMint: result.baseMint, + quoteMint: result.quoteMint, + }) + .signers([boundaryRecipient]) + .rpc(); + + const record = await liquidationClient.getRefundRecord({ + liquidation: result.liquidation, + recipient: boundaryRecipient.publicKey, + }); + assert.equal(record.baseBurned.toString(), "1000000000"); + assert.equal(record.quoteRefunded.toString(), "500000000"); + }); +} diff --git a/tests/liquidation/unit/setRefundRecord.test.ts b/tests/liquidation/unit/setRefundRecord.test.ts new file mode 100644 index 000000000..6d364c7c8 --- /dev/null +++ b/tests/liquidation/unit/setRefundRecord.test.ts @@ -0,0 +1,351 @@ +import { LiquidationClient } from "@metadaoproject/futarchy/v0.7"; +import { Keypair, PublicKey } from "@solana/web3.js"; +import { assert } from "chai"; +import { expectError } from "../../utils.js"; +import { setupLiquidation } from "../utils.js"; +import BN from "bn.js"; +import { ComputeBudgetProgram } from "@solana/web3.js"; + +export default function suite() { + let liquidationClient: LiquidationClient; + let baseMint: PublicKey; + let quoteMint: PublicKey; + let createKey: Keypair; + let recordAuthority: Keypair; + let liquidationAuthority: Keypair; + let liquidation: PublicKey; + + before(async function () { + liquidationClient = this.liquidation; + }); + + beforeEach(async function () { + const result = await setupLiquidation(this); + baseMint = result.baseMint; + quoteMint = result.quoteMint; + createKey = result.createKey; + recordAuthority = result.recordAuthority; + liquidationAuthority = result.liquidationAuthority; + liquidation = result.liquidation; + + await liquidationClient + .initializeLiquidationIx({ + durationSeconds: 86400, + createKey: createKey.publicKey, + recordAuthority: recordAuthority.publicKey, + liquidationAuthority: liquidationAuthority.publicKey, + baseMint, + quoteMint, + }) + .signers([createKey]) + .rpc(); + }); + + it("successfully creates a new refund record", async function () { + const recipient = Keypair.generate(); + const baseAssigned = new BN(1_000_000_000); + const quoteRefundable = new BN(500_000_000); + + await liquidationClient + .setRefundRecordIx({ + baseAssigned, + quoteRefundable, + recordAuthority: recordAuthority.publicKey, + liquidation, + recipient: recipient.publicKey, + }) + .signers([recordAuthority]) + .rpc(); + + const record = await liquidationClient.getRefundRecord({ + liquidation, + recipient: recipient.publicKey, + }); + assert.equal(record.baseAssigned.toString(), baseAssigned.toString()); + assert.equal(record.quoteRefundable.toString(), quoteRefundable.toString()); + assert.ok(record.liquidation.equals(liquidation)); + assert.ok(record.recipient.equals(recipient.publicKey)); + assert.equal(record.baseBurned.toString(), "0"); + assert.equal(record.quoteRefunded.toString(), "0"); + + const liq = await liquidationClient.fetchLiquidation(liquidation); + assert.equal(liq.totalBaseAssigned.toString(), baseAssigned.toString()); + assert.equal( + liq.totalQuoteRefundable.toString(), + quoteRefundable.toString(), + ); + assert.equal(liq.seqNum.toString(), "1"); + }); + + it("successfully updates an existing refund record (increase)", async function () { + const recipient = Keypair.generate(); + + await liquidationClient + .setRefundRecordIx({ + baseAssigned: new BN(100_000_000), + quoteRefundable: new BN(50_000_000), + recordAuthority: recordAuthority.publicKey, + liquidation, + recipient: recipient.publicKey, + }) + .signers([recordAuthority]) + .rpc(); + + // Increase values + await liquidationClient + .setRefundRecordIx({ + baseAssigned: new BN(200_000_000), + quoteRefundable: new BN(100_000_000), + recordAuthority: recordAuthority.publicKey, + liquidation, + recipient: recipient.publicKey, + }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) + .signers([recordAuthority]) + .rpc(); + + const record = await liquidationClient.getRefundRecord({ + liquidation, + recipient: recipient.publicKey, + }); + assert.equal(record.baseAssigned.toString(), "200000000"); + assert.equal(record.quoteRefundable.toString(), "100000000"); + + const liq = await liquidationClient.fetchLiquidation(liquidation); + assert.equal(liq.totalBaseAssigned.toString(), "200000000"); + assert.equal(liq.totalQuoteRefundable.toString(), "100000000"); + }); + + it("successfully updates an existing refund record (decrease)", async function () { + const recipient = Keypair.generate(); + + await liquidationClient + .setRefundRecordIx({ + baseAssigned: new BN(200_000_000), + quoteRefundable: new BN(100_000_000), + recordAuthority: recordAuthority.publicKey, + liquidation, + recipient: recipient.publicKey, + }) + .signers([recordAuthority]) + .rpc(); + + // Decrease values + await liquidationClient + .setRefundRecordIx({ + baseAssigned: new BN(50_000_000), + quoteRefundable: new BN(25_000_000), + recordAuthority: recordAuthority.publicKey, + liquidation, + recipient: recipient.publicKey, + }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) + .signers([recordAuthority]) + .rpc(); + + const record = await liquidationClient.getRefundRecord({ + liquidation, + recipient: recipient.publicKey, + }); + assert.equal(record.baseAssigned.toString(), "50000000"); + assert.equal(record.quoteRefundable.toString(), "25000000"); + + const liq = await liquidationClient.fetchLiquidation(liquidation); + assert.equal(liq.totalBaseAssigned.toString(), "50000000"); + assert.equal(liq.totalQuoteRefundable.toString(), "25000000"); + }); + + it("correctly tracks totals across multiple records", async function () { + const recipient1 = Keypair.generate(); + const recipient2 = Keypair.generate(); + const recipient3 = Keypair.generate(); + + await liquidationClient + .setRefundRecordIx({ + baseAssigned: new BN(100_000_000), + quoteRefundable: new BN(50_000_000), + recordAuthority: recordAuthority.publicKey, + liquidation, + recipient: recipient1.publicKey, + }) + .signers([recordAuthority]) + .rpc(); + + await liquidationClient + .setRefundRecordIx({ + baseAssigned: new BN(200_000_000), + quoteRefundable: new BN(100_000_000), + recordAuthority: recordAuthority.publicKey, + liquidation, + recipient: recipient2.publicKey, + }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) + .signers([recordAuthority]) + .rpc(); + + await liquidationClient + .setRefundRecordIx({ + baseAssigned: new BN(300_000_000), + quoteRefundable: new BN(150_000_000), + recordAuthority: recordAuthority.publicKey, + liquidation, + recipient: recipient3.publicKey, + }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_002 }), + ]) + .signers([recordAuthority]) + .rpc(); + + const liq = await liquidationClient.fetchLiquidation(liquidation); + // 100 + 200 + 300 = 600 + assert.equal(liq.totalBaseAssigned.toString(), "600000000"); + // 50 + 100 + 150 = 300 + assert.equal(liq.totalQuoteRefundable.toString(), "300000000"); + assert.equal(liq.seqNum.toString(), "3"); + }); + + it("allows setting record to zero allocation", async function () { + const recipient = Keypair.generate(); + + // First create a non-zero record + await liquidationClient + .setRefundRecordIx({ + baseAssigned: new BN(100_000_000), + quoteRefundable: new BN(50_000_000), + recordAuthority: recordAuthority.publicKey, + liquidation, + recipient: recipient.publicKey, + }) + .signers([recordAuthority]) + .rpc(); + + // Set to zero + await liquidationClient + .setRefundRecordIx({ + baseAssigned: new BN(0), + quoteRefundable: new BN(0), + recordAuthority: recordAuthority.publicKey, + liquidation, + recipient: recipient.publicKey, + }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) + .signers([recordAuthority]) + .rpc(); + + const record = await liquidationClient.getRefundRecord({ + liquidation, + recipient: recipient.publicKey, + }); + assert.equal(record.baseAssigned.toString(), "0"); + assert.equal(record.quoteRefundable.toString(), "0"); + + const liq = await liquidationClient.fetchLiquidation(liquidation); + assert.equal(liq.totalBaseAssigned.toString(), "0"); + assert.equal(liq.totalQuoteRefundable.toString(), "0"); + }); + + it("throws error when quote_refundable > 0 but base_assigned is 0", async function () { + const recipient = Keypair.generate(); + + const callbacks = expectError( + "InvalidAllocation", + "Should have thrown InvalidAllocation error", + ); + + await liquidationClient + .setRefundRecordIx({ + baseAssigned: new BN(0), + quoteRefundable: new BN(50_000_000), + recordAuthority: recordAuthority.publicKey, + liquidation, + recipient: recipient.publicKey, + }) + .signers([recordAuthority]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("throws error when liquidation is already activated", async function () { + const recipient = Keypair.generate(); + + // Create a record so activation has something to fund + await liquidationClient + .setRefundRecordIx({ + baseAssigned: new BN(100_000_000), + quoteRefundable: new BN(50_000_000), + recordAuthority: recordAuthority.publicKey, + liquidation, + recipient: recipient.publicKey, + }) + .signers([recordAuthority]) + .rpc(); + + // Fund and activate the liquidation + await this.mintTo( + quoteMint, + liquidationAuthority.publicKey, + this.payer, + 50_000_000, + ); + + await liquidationClient + .activateLiquidationIx({ + liquidationAuthority: liquidationAuthority.publicKey, + liquidation, + quoteMint, + }) + .signers([liquidationAuthority]) + .rpc(); + + // Now try to set a refund record — should fail + const recipient2 = Keypair.generate(); + + const callbacks = expectError( + "AlreadyActivated", + "Should have thrown AlreadyActivated error", + ); + + await liquidationClient + .setRefundRecordIx({ + baseAssigned: new BN(100_000_000), + quoteRefundable: new BN(50_000_000), + recordAuthority: recordAuthority.publicKey, + liquidation, + recipient: recipient2.publicKey, + }) + .signers([recordAuthority]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("throws error when record_authority does not match", async function () { + const recipient = Keypair.generate(); + const wrongAuthority = Keypair.generate(); + + const callbacks = expectError( + "InvalidAuthority", + "Should have thrown InvalidAuthority error", + ); + + await liquidationClient + .setRefundRecordIx({ + baseAssigned: new BN(100_000_000), + quoteRefundable: new BN(50_000_000), + recordAuthority: wrongAuthority.publicKey, + liquidation, + recipient: recipient.publicKey, + }) + .signers([wrongAuthority]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); +} diff --git a/tests/liquidation/unit/withdrawRemainingQuote.test.ts b/tests/liquidation/unit/withdrawRemainingQuote.test.ts new file mode 100644 index 000000000..a0a68d79f --- /dev/null +++ b/tests/liquidation/unit/withdrawRemainingQuote.test.ts @@ -0,0 +1,328 @@ +import { LiquidationClient } from "@metadaoproject/futarchy/v0.7"; +import { Keypair, PublicKey, ComputeBudgetProgram } from "@solana/web3.js"; +import { assert } from "chai"; +import { expectError } from "../../utils.js"; +import { + setupActivatedLiquidation, + setupLiquidationWithRefundRecords, +} from "../utils.js"; +import BN from "bn.js"; + +export default function suite() { + let liquidationClient: LiquidationClient; + + before(async function () { + liquidationClient = this.liquidation; + }); + + it("successfully withdraws remaining quote after deadline", async function () { + const recipient = Keypair.generate(); + + const result = await setupActivatedLiquidation( + this, + [ + { + recipient, + baseAssigned: new BN(1_000_000_000), + quoteRefundable: new BN(500_000_000), + }, + ], + { durationSeconds: 100 }, + ); + + // Advance past the deadline + await this.advanceBySeconds(101); + + await liquidationClient + .withdrawRemainingQuoteIx({ + liquidationAuthority: result.liquidationAuthority.publicKey, + liquidation: result.liquidation, + quoteMint: result.quoteMint, + }) + .signers([result.liquidationAuthority]) + .rpc(); + + // Vault should be empty + const vaultBalance = await this.getTokenBalance( + result.quoteMint, + result.liquidation, + ); + assert.equal(vaultBalance.toString(), "0"); + + // Authority should have received all 500 tokens + const authorityBalance = await this.getTokenBalance( + result.quoteMint, + result.liquidationAuthority.publicKey, + ); + assert.equal(authorityBalance.toString(), "500000000"); + }); + + it("withdraws correct amount after partial refunds", async function () { + const userFull = Keypair.generate(); + const userPartial = Keypair.generate(); + const userNone = Keypair.generate(); + + const result = await setupActivatedLiquidation( + this, + [ + { + recipient: userFull, + baseAssigned: new BN(1_000_000_000), + quoteRefundable: new BN(500_000_000), + }, + { + recipient: userPartial, + baseAssigned: new BN(1_000_000_000), + quoteRefundable: new BN(500_000_000), + }, + { + recipient: userNone, + baseAssigned: new BN(1_000_000_000), + quoteRefundable: new BN(500_000_000), + }, + ], + { durationSeconds: 100 }, + ); + + // User 1: full refund - mint all 1000 base tokens and refund + await this.mintTo( + result.baseMint, + userFull.publicKey, + result.baseMintAuthority, + 1_000_000_000, + ); + + await liquidationClient + .refundIx({ + recipient: userFull.publicKey, + liquidation: result.liquidation, + baseMint: result.baseMint, + quoteMint: result.quoteMint, + }) + .signers([userFull]) + .rpc(); + + // User 2: partial refund - mint 400 of 1000 base tokens + // Burns 400, gets 500 * 400 / 1000 = 200 + await this.mintTo( + result.baseMint, + userPartial.publicKey, + result.baseMintAuthority, + 400_000_000, + ); + + await liquidationClient + .refundIx({ + recipient: userPartial.publicKey, + liquidation: result.liquidation, + baseMint: result.baseMint, + quoteMint: result.quoteMint, + }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) + .signers([userPartial]) + .rpc(); + + // User 3: no refund at all + + // Total funded: 1500, refunded: 500 + 200 = 700, remaining: 800 + // Advance past the deadline + await this.advanceBySeconds(101); + + await liquidationClient + .withdrawRemainingQuoteIx({ + liquidationAuthority: result.liquidationAuthority.publicKey, + liquidation: result.liquidation, + quoteMint: result.quoteMint, + }) + .signers([result.liquidationAuthority]) + .rpc(); + + // Vault should be empty + const vaultBalance = await this.getTokenBalance( + result.quoteMint, + result.liquidation, + ); + assert.equal(vaultBalance.toString(), "0"); + + // Authority should have received remaining 800 tokens + const authorityBalance = await this.getTokenBalance( + result.quoteMint, + result.liquidationAuthority.publicKey, + ); + assert.equal(authorityBalance.toString(), "800000000"); + }); + + it("throws error when liquidation_authority does not match", async function () { + const recipient = Keypair.generate(); + const wrongAuthority = Keypair.generate(); + + const result = await setupActivatedLiquidation( + this, + [ + { + recipient, + baseAssigned: new BN(1_000_000_000), + quoteRefundable: new BN(500_000_000), + }, + ], + { durationSeconds: 100 }, + ); + + await this.advanceBySeconds(101); + + // Create the wrong authority's quote token account so the instruction + // gets past account validation and hits the has_one constraint + await this.createTokenAccount(result.quoteMint, wrongAuthority.publicKey); + + const callbacks = expectError( + "InvalidAuthority", + "Should have thrown InvalidAuthority error", + ); + + await liquidationClient + .withdrawRemainingQuoteIx({ + liquidationAuthority: wrongAuthority.publicKey, + liquidation: result.liquidation, + quoteMint: result.quoteMint, + }) + .signers([wrongAuthority]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("throws error when refund window has not expired", async function () { + const recipient = Keypair.generate(); + + const result = await setupActivatedLiquidation( + this, + [ + { + recipient, + baseAssigned: new BN(1_000_000_000), + quoteRefundable: new BN(500_000_000), + }, + ], + { durationSeconds: 100 }, + ); + + // Do NOT advance past deadline + + const callbacks = expectError( + "RefundWindowNotExpired", + "Should have thrown RefundWindowNotExpired error", + ); + + await liquidationClient + .withdrawRemainingQuoteIx({ + liquidationAuthority: result.liquidationAuthority.publicKey, + liquidation: result.liquidation, + quoteMint: result.quoteMint, + }) + .signers([result.liquidationAuthority]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("throws error when liquidation was never activated", async function () { + const recipient = Keypair.generate(); + + const result = await setupLiquidationWithRefundRecords( + this, + [ + { + recipient, + baseAssigned: new BN(1_000_000_000), + quoteRefundable: new BN(500_000_000), + }, + ], + { durationSeconds: 100 }, + ); + + await this.advanceBySeconds(101); + + // Create the authority's quote token account so the instruction + // gets past account validation and hits the is_refunding constraint + await this.createTokenAccount( + result.quoteMint, + result.liquidationAuthority.publicKey, + ); + + const callbacks = expectError( + "RefundingNotEnabled", + "Should have thrown RefundingNotEnabled error", + ); + + await liquidationClient + .withdrawRemainingQuoteIx({ + liquidationAuthority: result.liquidationAuthority.publicKey, + liquidation: result.liquidation, + quoteMint: result.quoteMint, + }) + .signers([result.liquidationAuthority]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("can be called multiple times (second call transfers 0)", async function () { + const recipient = Keypair.generate(); + + const result = await setupActivatedLiquidation( + this, + [ + { + recipient, + baseAssigned: new BN(1_000_000_000), + quoteRefundable: new BN(500_000_000), + }, + ], + { durationSeconds: 100 }, + ); + + await this.advanceBySeconds(101); + + // First withdrawal + await liquidationClient + .withdrawRemainingQuoteIx({ + liquidationAuthority: result.liquidationAuthority.publicKey, + liquidation: result.liquidation, + quoteMint: result.quoteMint, + }) + .signers([result.liquidationAuthority]) + .rpc(); + + const authorityBalanceAfterFirst = await this.getTokenBalance( + result.quoteMint, + result.liquidationAuthority.publicKey, + ); + assert.equal(authorityBalanceAfterFirst.toString(), "500000000"); + + // Second withdrawal succeeds but transfers 0 + await liquidationClient + .withdrawRemainingQuoteIx({ + liquidationAuthority: result.liquidationAuthority.publicKey, + liquidation: result.liquidation, + quoteMint: result.quoteMint, + }) + .postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_001 }), + ]) + .signers([result.liquidationAuthority]) + .rpc(); + + // Balance unchanged + const authorityBalanceAfterSecond = await this.getTokenBalance( + result.quoteMint, + result.liquidationAuthority.publicKey, + ); + assert.equal(authorityBalanceAfterSecond.toString(), "500000000"); + + // Vault still empty + const vaultBalance = await this.getTokenBalance( + result.quoteMint, + result.liquidation, + ); + assert.equal(vaultBalance.toString(), "0"); + }); +} diff --git a/tests/liquidation/utils.ts b/tests/liquidation/utils.ts new file mode 100644 index 000000000..7921ddfa8 --- /dev/null +++ b/tests/liquidation/utils.ts @@ -0,0 +1,149 @@ +import { + PublicKey, + Keypair, + ComputeBudgetProgram, + SystemProgram, +} from "@solana/web3.js"; +import { LiquidationClient } from "@metadaoproject/futarchy/v0.7"; +import BN from "bn.js"; + +export async function setupLiquidation(ctx: Mocha.Context): Promise<{ + baseMint: PublicKey; + quoteMint: PublicKey; + baseMintAuthority: Keypair; + createKey: Keypair; + recordAuthority: Keypair; + liquidationAuthority: Keypair; + liquidation: PublicKey; +}> { + const baseMintAuthority = Keypair.generate(); + const baseMint = await ctx.createMint(baseMintAuthority.publicKey, 6); + const quoteMint = await ctx.createMint(ctx.payer.publicKey, 6); + + const createKey = Keypair.generate(); + const recordAuthority = Keypair.generate(); + const liquidationAuthority = Keypair.generate(); + + const liquidationClient = ctx.liquidation as LiquidationClient; + const liquidation = liquidationClient.getLiquidationAddress({ + baseMint, + quoteMint, + createKey: createKey.publicKey, + }); + + return { + baseMint, + quoteMint, + baseMintAuthority, + createKey, + recordAuthority, + liquidationAuthority, + liquidation, + }; +} + +export interface RefundRecordSetup { + recipient: Keypair; + baseAssigned: BN; + quoteRefundable: BN; +} + +export async function setupLiquidationWithRefundRecords( + ctx: Mocha.Context, + records: RefundRecordSetup[], + opts?: { durationSeconds?: number }, +): Promise<{ + baseMint: PublicKey; + quoteMint: PublicKey; + baseMintAuthority: Keypair; + createKey: Keypair; + recordAuthority: Keypair; + liquidationAuthority: Keypair; + liquidation: PublicKey; +}> { + const result = await setupLiquidation(ctx); + const liquidationClient = ctx.liquidation as LiquidationClient; + + await liquidationClient + .initializeLiquidationIx({ + durationSeconds: opts?.durationSeconds ?? 86400, + createKey: result.createKey.publicKey, + recordAuthority: result.recordAuthority.publicKey, + liquidationAuthority: result.liquidationAuthority.publicKey, + baseMint: result.baseMint, + quoteMint: result.quoteMint, + }) + .signers([result.createKey]) + .rpc(); + + for (let i = 0; i < records.length; i++) { + const record = records[i]; + const builder = liquidationClient.setRefundRecordIx({ + baseAssigned: record.baseAssigned, + quoteRefundable: record.quoteRefundable, + recordAuthority: result.recordAuthority.publicKey, + liquidation: result.liquidation, + recipient: record.recipient.publicKey, + }); + + if (i > 0) { + builder.postInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 + i }), + ]); + } + + await builder.signers([result.recordAuthority]).rpc(); + } + + return result; +} + +export async function setupActivatedLiquidation( + ctx: Mocha.Context, + records: RefundRecordSetup[], + opts?: { durationSeconds?: number }, +): Promise<{ + baseMint: PublicKey; + quoteMint: PublicKey; + baseMintAuthority: Keypair; + createKey: Keypair; + recordAuthority: Keypair; + liquidationAuthority: Keypair; + liquidation: PublicKey; +}> { + const result = await setupLiquidationWithRefundRecords(ctx, records, opts); + const liquidationClient = ctx.liquidation as LiquidationClient; + + // Fund recipients with SOL for rent (needed for init_if_needed quote ATA) + for (const record of records) { + ctx.context.setAccount(record.recipient.publicKey, { + data: Buffer.alloc(0), + executable: false, + owner: SystemProgram.programId, + lamports: 1_000_000_000, + }); + } + + const totalQuoteRefundable = records.reduce( + (sum, r) => sum.add(r.quoteRefundable), + new BN(0), + ); + + await ctx.mintTo( + result.quoteMint, + result.liquidationAuthority.publicKey, + ctx.payer, + totalQuoteRefundable.toNumber(), + ); + + await liquidationClient + .activateLiquidationIx({ + liquidationAuthority: result.liquidationAuthority.publicKey, + liquidation: result.liquidation, + quoteMint: result.quoteMint, + }) + .signers([result.liquidationAuthority]) + .rpc(); + + return result; +} diff --git a/tests/main.test.ts b/tests/main.test.ts index 35875f199..f2215919b 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -6,6 +6,7 @@ import priceBasedPerformancePackage from "./priceBasedPerformancePackage/main.te import bidWall from "./bidWall/main.test.js"; import mintGovernor from "./mintGovernor/main.test.js"; import performancePackageV2 from "./performancePackageV2/main.test.js"; +import liquidation from "./liquidation/main.test.js"; import { BanksClient, @@ -37,6 +38,7 @@ import { MAINNET_METEORA_CONFIG, BidWallClient, MintGovernorClient, + LiquidationClient, } from "@metadaoproject/futarchy/v0.7"; import { LaunchpadClient as LaunchpadClientV6 } from "@metadaoproject/futarchy/v0.6"; @@ -93,6 +95,7 @@ export interface TestContext { priceBasedPerformancePackage: PriceBasedPerformancePackageClient; bidWall: BidWallClient; mintGovernor: MintGovernorClient; + liquidation: LiquidationClient; payer: Keypair; squadsConnection: Connection; createTokenAccount: (mint: PublicKey, owner: PublicKey) => Promise; @@ -264,6 +267,9 @@ before(async function () { this.bidWall = BidWallClient.createClient({ provider: provider as any, }); + this.liquidation = LiquidationClient.createClient({ + provider: provider as any, + }); this.provider = provider; this.payer = provider.wallet.payer; @@ -743,6 +749,7 @@ describe("futarchy", futarchy); describe("bid_wall", bidWall); describe("mint_governor", mintGovernor); describe("performance_package_v2", performancePackageV2); +describe("liquidation", liquidation); describe("project-wide integration tests", function () { it.skip("mint and swap in a single transaction", mintAndSwap); describe("full launch v6", fullLaunch); diff --git a/yarn.lock b/yarn.lock index a06b93872..3d6230b68 100644 --- a/yarn.lock +++ b/yarn.lock @@ -975,7 +975,7 @@ "@jridgewell/sourcemap-codec" "^1.4.10" "@metadaoproject/futarchy@./sdk": - version "0.7.0-alpha.14" + version "0.7.1-alpha.5" dependencies: "@coral-xyz/anchor" "^0.29.0" "@metaplex-foundation/umi" "^0.9.2"