From 2ccf35842f0ff549b231b3dcbae074761711f33e Mon Sep 17 00:00:00 2001 From: Pileks Date: Wed, 4 Mar 2026 18:52:11 +0100 Subject: [PATCH 01/35] liquidation program scaffold --- Anchor.toml | 1 + CLAUDE.md | 3 + Cargo.lock | 9 + programs/liquidation/Cargo.toml | 21 + programs/liquidation/Xargo.toml | 2 + programs/liquidation/src/error.rs | 23 + programs/liquidation/src/events.rs | 67 ++ programs/liquidation/src/instructions/mod.rs | 1 + programs/liquidation/src/lib.rs | 30 + programs/liquidation/src/state/liquidation.rs | 53 ++ programs/liquidation/src/state/mod.rs | 3 + sdk/src/v0.7/types/liquidation.ts | 779 ++++++++++++++++++ 12 files changed, 992 insertions(+) create mode 100644 programs/liquidation/Cargo.toml create mode 100644 programs/liquidation/Xargo.toml create mode 100644 programs/liquidation/src/error.rs create mode 100644 programs/liquidation/src/events.rs create mode 100644 programs/liquidation/src/instructions/mod.rs create mode 100644 programs/liquidation/src/lib.rs create mode 100644 programs/liquidation/src/state/liquidation.rs create mode 100644 programs/liquidation/src/state/mod.rs create mode 100644 sdk/src/v0.7/types/liquidation.ts 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..2fb10ae81 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 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/programs/liquidation/Cargo.toml b/programs/liquidation/Cargo.toml new file mode 100644 index 000000000..20671c8fe --- /dev/null +++ b/programs/liquidation/Cargo.toml @@ -0,0 +1,21 @@ +[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 = [] + +[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..041c00a74 --- /dev/null +++ b/programs/liquidation/src/error.rs @@ -0,0 +1,23 @@ +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, +} diff --git a/programs/liquidation/src/events.rs b/programs/liquidation/src/events.rs new file mode 100644 index 000000000..b060e7bc3 --- /dev/null +++ b/programs/liquidation/src/events.rs @@ -0,0 +1,67 @@ +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 record_authority: Pubkey, + pub liquidation_authority: Pubkey, + pub base_mint: Pubkey, + pub quote_mint: Pubkey, + pub duration_seconds: u32, +} + +#[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 recipient: Pubkey, + pub base_assigned: u64, + pub quote_refundable: u64, +} + +#[event] +pub struct RefundEvent { + pub common: CommonFields, + pub liquidation: 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/mod.rs b/programs/liquidation/src/instructions/mod.rs new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/programs/liquidation/src/instructions/mod.rs @@ -0,0 +1 @@ + diff --git a/programs/liquidation/src/lib.rs b/programs/liquidation/src/lib.rs new file mode 100644 index 000000000..52cbe1e3f --- /dev/null +++ b/programs/liquidation/src/lib.rs @@ -0,0 +1,30 @@ +//! 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::*; +} diff --git a/programs/liquidation/src/state/liquidation.rs b/programs/liquidation/src/state/liquidation.rs new file mode 100644 index 000000000..010714f9a --- /dev/null +++ b/programs/liquidation/src/state/liquidation.rs @@ -0,0 +1,53 @@ +use anchor_lang::prelude::*; + +#[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, +} + +#[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/programs/liquidation/src/state/mod.rs b/programs/liquidation/src/state/mod.rs new file mode 100644 index 000000000..d8fcf0b9c --- /dev/null +++ b/programs/liquidation/src/state/mod.rs @@ -0,0 +1,3 @@ +pub mod liquidation; + +pub use liquidation::*; diff --git a/sdk/src/v0.7/types/liquidation.ts b/sdk/src/v0.7/types/liquidation.ts new file mode 100644 index 000000000..97713bd40 --- /dev/null +++ b/sdk/src/v0.7/types/liquidation.ts @@ -0,0 +1,779 @@ +export type Liquidation = { + version: "0.1.0"; + name: "liquidation"; + instructions: []; + 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"; + }, + ]; + }; + }, + ]; + events: [ + { + name: "LiquidationCreatedEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "liquidation"; + 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: "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: "recipient"; + type: "publicKey"; + index: false; + }, + { + name: "baseAssigned"; + type: "u64"; + index: false; + }, + { + name: "quoteRefundable"; + type: "u64"; + index: false; + }, + ]; + }, + { + name: "RefundEvent"; + fields: [ + { + name: "common"; + type: { + defined: "CommonFields"; + }; + index: false; + }, + { + name: "liquidation"; + 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"; + }, + ]; +}; + +export const IDL: Liquidation = { + version: "0.1.0", + name: "liquidation", + instructions: [], + 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", + }, + ], + }, + }, + ], + events: [ + { + name: "LiquidationCreatedEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "liquidation", + 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: "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: "recipient", + type: "publicKey", + index: false, + }, + { + name: "baseAssigned", + type: "u64", + index: false, + }, + { + name: "quoteRefundable", + type: "u64", + index: false, + }, + ], + }, + { + name: "RefundEvent", + fields: [ + { + name: "common", + type: { + defined: "CommonFields", + }, + index: false, + }, + { + name: "liquidation", + 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", + }, + ], +}; From 54871747c5f1fa7193849db018b6679a14520457 Mon Sep 17 00:00:00 2001 From: Pileks Date: Wed, 4 Mar 2026 18:57:30 +0100 Subject: [PATCH 02/35] scaffolding cleanup --- programs/liquidation/src/state/liquidation.rs | 19 ------------------ programs/liquidation/src/state/mod.rs | 2 ++ .../liquidation/src/state/refund_record.rs | 20 +++++++++++++++++++ 3 files changed, 22 insertions(+), 19 deletions(-) create mode 100644 programs/liquidation/src/state/refund_record.rs diff --git a/programs/liquidation/src/state/liquidation.rs b/programs/liquidation/src/state/liquidation.rs index 010714f9a..b0168b843 100644 --- a/programs/liquidation/src/state/liquidation.rs +++ b/programs/liquidation/src/state/liquidation.rs @@ -32,22 +32,3 @@ pub struct Liquidation { /// PDA bump seed. pub pda_bump: u8, } - -#[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/programs/liquidation/src/state/mod.rs b/programs/liquidation/src/state/mod.rs index d8fcf0b9c..c5632ad22 100644 --- a/programs/liquidation/src/state/mod.rs +++ b/programs/liquidation/src/state/mod.rs @@ -1,3 +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..29330c870 --- /dev/null +++ b/programs/liquidation/src/state/refund_record.rs @@ -0,0 +1,20 @@ +use anchor_lang::prelude::*; + +#[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, +} From adcc929b6768dce72db783a0de188b3c1e119ca7 Mon Sep 17 00:00:00 2001 From: Pileks Date: Wed, 4 Mar 2026 19:04:06 +0100 Subject: [PATCH 03/35] sdk scaffolding --- sdk/src/v0.7/LiquidationClient.ts | 110 ++++++++++++++++++++++++++++++ sdk/src/v0.7/constants.ts | 3 + sdk/src/v0.7/index.ts | 1 + sdk/src/v0.7/types/index.ts | 10 +++ sdk/src/v0.7/utils/pda.ts | 42 ++++++++++++ tests/liquidation/main.test.ts | 17 +++++ tests/liquidation/utils.ts | 37 ++++++++++ tests/main.test.ts | 7 ++ 8 files changed, 227 insertions(+) create mode 100644 sdk/src/v0.7/LiquidationClient.ts create mode 100644 tests/liquidation/main.test.ts create mode 100644 tests/liquidation/utils.ts diff --git a/sdk/src/v0.7/LiquidationClient.ts b/sdk/src/v0.7/LiquidationClient.ts new file mode 100644 index 000000000..9050e9c32 --- /dev/null +++ b/sdk/src/v0.7/LiquidationClient.ts @@ -0,0 +1,110 @@ +import { AnchorProvider, Program } from "@coral-xyz/anchor"; +import { AccountInfo, PublicKey } from "@solana/web3.js"; +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, + ); + } + + 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/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..d8e77a895 --- /dev/null +++ b/tests/liquidation/main.test.ts @@ -0,0 +1,17 @@ +import { LiquidationClient } from "@metadaoproject/futarchy/v0.7"; +import { BankrunProvider } from "anchor-bankrun"; + +export default function suite() { + before(async function () { + const provider = new BankrunProvider(this.context); + this.liquidation = LiquidationClient.createClient({ + provider: provider as any, + }); + }); + + describe("#initialize_liquidation", function () {}); + describe("#set_refund_record", function () {}); + describe("#activate_liquidation", function () {}); + describe("#refund", function () {}); + describe("#withdraw_remaining_quote", function () {}); +} diff --git a/tests/liquidation/utils.ts b/tests/liquidation/utils.ts new file mode 100644 index 000000000..e6e14685f --- /dev/null +++ b/tests/liquidation/utils.ts @@ -0,0 +1,37 @@ +import { PublicKey, Keypair } from "@solana/web3.js"; +import { LiquidationClient } from "@metadaoproject/futarchy/v0.7"; + +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, + }; +} 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); From 50c810802f62283364f37ad21a70eb4afd07a1bd Mon Sep 17 00:00:00 2001 From: Pileks Date: Wed, 4 Mar 2026 19:25:59 +0100 Subject: [PATCH 04/35] initialize instruction --- .../instructions/initialize_liquidation.rs | 94 +++++++++ programs/liquidation/src/instructions/mod.rs | 2 + programs/liquidation/src/lib.rs | 8 + sdk/src/v0.7/types/liquidation.ts | 186 +++++++++++++++++- 4 files changed, 288 insertions(+), 2 deletions(-) create mode 100644 programs/liquidation/src/instructions/initialize_liquidation.rs diff --git a/programs/liquidation/src/instructions/initialize_liquidation.rs b/programs/liquidation/src/instructions/initialize_liquidation.rs new file mode 100644 index 000000000..ad4911755 --- /dev/null +++ b/programs/liquidation/src/instructions/initialize_liquidation.rs @@ -0,0 +1,94 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token::{Mint, Token, TokenAccount}, +}; + +use crate::{ + error::LiquidationError, + events::{CommonFields, LiquidationCreatedEvent}, + state::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 = [b"liquidation", base_mint.key().as_ref(), quote_mint.key().as_ref(), create_key.key().as_ref()], + bump + )] + pub liquidation: Account<'info, Liquidation>, + + #[account( + init, + 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<()> { + 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(), + 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, + }); + + Ok(()) + } +} diff --git a/programs/liquidation/src/instructions/mod.rs b/programs/liquidation/src/instructions/mod.rs index 8b1378917..3ff852899 100644 --- a/programs/liquidation/src/instructions/mod.rs +++ b/programs/liquidation/src/instructions/mod.rs @@ -1 +1,3 @@ +pub mod initialize_liquidation; +pub use initialize_liquidation::*; diff --git a/programs/liquidation/src/lib.rs b/programs/liquidation/src/lib.rs index 52cbe1e3f..f01e21006 100644 --- a/programs/liquidation/src/lib.rs +++ b/programs/liquidation/src/lib.rs @@ -27,4 +27,12 @@ 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) + } } diff --git a/sdk/src/v0.7/types/liquidation.ts b/sdk/src/v0.7/types/liquidation.ts index 97713bd40..1294267aa 100644 --- a/sdk/src/v0.7/types/liquidation.ts +++ b/sdk/src/v0.7/types/liquidation.ts @@ -1,7 +1,86 @@ export type Liquidation = { version: "0.1.0"; name: "liquidation"; - instructions: []; + 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"; + }; + }, + ]; + }, + ]; accounts: [ { name: "liquidation"; @@ -156,6 +235,18 @@ export type Liquidation = { ]; }; }, + { + name: "InitializeLiquidationArgs"; + type: { + kind: "struct"; + fields: [ + { + name: "durationSeconds"; + type: "u32"; + }, + ]; + }; + }, ]; events: [ { @@ -391,7 +482,86 @@ export type Liquidation = { export const IDL: Liquidation = { version: "0.1.0", name: "liquidation", - instructions: [], + 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", + }, + }, + ], + }, + ], accounts: [ { name: "liquidation", @@ -546,6 +716,18 @@ export const IDL: Liquidation = { ], }, }, + { + name: "InitializeLiquidationArgs", + type: { + kind: "struct", + fields: [ + { + name: "durationSeconds", + type: "u32", + }, + ], + }, + }, ], events: [ { From 56597ac665c6fd9d0a9cbc2098ae65d745a91dd9 Mon Sep 17 00:00:00 2001 From: Pileks Date: Wed, 4 Mar 2026 19:31:08 +0100 Subject: [PATCH 05/35] liquidation initialize sdk --- sdk/src/v0.7/LiquidationClient.ts | 70 ++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/sdk/src/v0.7/LiquidationClient.ts b/sdk/src/v0.7/LiquidationClient.ts index 9050e9c32..2df15f448 100644 --- a/sdk/src/v0.7/LiquidationClient.ts +++ b/sdk/src/v0.7/LiquidationClient.ts @@ -1,5 +1,10 @@ import { AnchorProvider, Program } from "@coral-xyz/anchor"; -import { AccountInfo, PublicKey } from "@solana/web3.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, @@ -82,6 +87,69 @@ export class LiquidationClient { ); } + 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, + }); + } + public getLiquidationAddress({ baseMint, quoteMint, From 9ffeded9080331575bd83e31c7466970e2408ed2 Mon Sep 17 00:00:00 2001 From: Pileks Date: Wed, 4 Mar 2026 19:35:58 +0100 Subject: [PATCH 06/35] liquidation init test --- tests/liquidation/main.test.ts | 3 +- .../unit/initializeLiquidation.test.ts | 94 +++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 tests/liquidation/unit/initializeLiquidation.test.ts diff --git a/tests/liquidation/main.test.ts b/tests/liquidation/main.test.ts index d8e77a895..d2639e39e 100644 --- a/tests/liquidation/main.test.ts +++ b/tests/liquidation/main.test.ts @@ -1,5 +1,6 @@ import { LiquidationClient } from "@metadaoproject/futarchy/v0.7"; import { BankrunProvider } from "anchor-bankrun"; +import initializeLiquidation from "./unit/initializeLiquidation.test.js"; export default function suite() { before(async function () { @@ -9,7 +10,7 @@ export default function suite() { }); }); - describe("#initialize_liquidation", function () {}); + describe.only("#initialize_liquidation", initializeLiquidation); describe("#set_refund_record", function () {}); describe("#activate_liquidation", function () {}); describe("#refund", function () {}); diff --git a/tests/liquidation/unit/initializeLiquidation.test.ts b/tests/liquidation/unit/initializeLiquidation.test.ts new file mode 100644 index 000000000..c4b844bf5 --- /dev/null +++ b/tests/liquidation/unit/initializeLiquidation.test.ts @@ -0,0 +1,94 @@ +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 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]); + }); +} From 5d362b06ce287104d6ec1614693e142b0d4a0cde Mon Sep 17 00:00:00 2001 From: Pileks Date: Wed, 4 Mar 2026 20:02:41 +0100 Subject: [PATCH 07/35] set refund record ix --- .../instructions/initialize_liquidation.rs | 5 +- programs/liquidation/src/instructions/mod.rs | 2 + .../src/instructions/set_refund_record.rs | 113 ++++++++++++++ programs/liquidation/src/lib.rs | 8 + programs/liquidation/src/state/liquidation.rs | 2 + .../liquidation/src/state/refund_record.rs | 2 + sdk/src/v0.7/types/liquidation.ts | 138 ++++++++++++++++++ 7 files changed, 268 insertions(+), 2 deletions(-) create mode 100644 programs/liquidation/src/instructions/set_refund_record.rs diff --git a/programs/liquidation/src/instructions/initialize_liquidation.rs b/programs/liquidation/src/instructions/initialize_liquidation.rs index ad4911755..53991cf87 100644 --- a/programs/liquidation/src/instructions/initialize_liquidation.rs +++ b/programs/liquidation/src/instructions/initialize_liquidation.rs @@ -7,7 +7,7 @@ use anchor_spl::{ use crate::{ error::LiquidationError, events::{CommonFields, LiquidationCreatedEvent}, - state::Liquidation, + state::{Liquidation, SEED_LIQUIDATION}, }; #[derive(AnchorSerialize, AnchorDeserialize, Clone)] @@ -35,7 +35,7 @@ pub struct InitializeLiquidation<'info> { init, payer = payer, space = 8 + Liquidation::INIT_SPACE, - seeds = [b"liquidation", base_mint.key().as_ref(), quote_mint.key().as_ref(), create_key.key().as_ref()], + seeds = [SEED_LIQUIDATION, base_mint.key().as_ref(), quote_mint.key().as_ref(), create_key.key().as_ref()], bump )] pub liquidation: Account<'info, Liquidation>, @@ -55,6 +55,7 @@ pub struct InitializeLiquidation<'info> { impl InitializeLiquidation<'_> { pub fn validate(&self, args: &InitializeLiquidationArgs) -> Result<()> { + // Refund window must have a nonzero duration require_gt!(args.duration_seconds, 0, LiquidationError::InvalidDuration); Ok(()) } diff --git a/programs/liquidation/src/instructions/mod.rs b/programs/liquidation/src/instructions/mod.rs index 3ff852899..29176129a 100644 --- a/programs/liquidation/src/instructions/mod.rs +++ b/programs/liquidation/src/instructions/mod.rs @@ -1,3 +1,5 @@ pub mod initialize_liquidation; +pub mod set_refund_record; pub use initialize_liquidation::*; +pub use set_refund_record::*; 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..a2e782909 --- /dev/null +++ b/programs/liquidation/src/instructions/set_refund_record.rs @@ -0,0 +1,113 @@ +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, + has_one = record_authority, + )] + 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(), + recipient: ctx.accounts.recipient.key(), + base_assigned: args.base_assigned, + quote_refundable: args.quote_refundable, + }); + + Ok(()) + } +} diff --git a/programs/liquidation/src/lib.rs b/programs/liquidation/src/lib.rs index f01e21006..65ff5fcbb 100644 --- a/programs/liquidation/src/lib.rs +++ b/programs/liquidation/src/lib.rs @@ -35,4 +35,12 @@ pub mod liquidation { ) -> 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) + } } diff --git a/programs/liquidation/src/state/liquidation.rs b/programs/liquidation/src/state/liquidation.rs index b0168b843..f8c1ffa6f 100644 --- a/programs/liquidation/src/state/liquidation.rs +++ b/programs/liquidation/src/state/liquidation.rs @@ -1,5 +1,7 @@ use anchor_lang::prelude::*; +pub const SEED_LIQUIDATION: &[u8] = b"liquidation"; + #[account] #[derive(InitSpace)] pub struct Liquidation { diff --git a/programs/liquidation/src/state/refund_record.rs b/programs/liquidation/src/state/refund_record.rs index 29330c870..c4660665c 100644 --- a/programs/liquidation/src/state/refund_record.rs +++ b/programs/liquidation/src/state/refund_record.rs @@ -1,5 +1,7 @@ use anchor_lang::prelude::*; +pub const SEED_REFUND_RECORD: &[u8] = b"refund_record"; + #[account] #[derive(InitSpace)] pub struct RefundRecord { diff --git a/sdk/src/v0.7/types/liquidation.ts b/sdk/src/v0.7/types/liquidation.ts index 1294267aa..e699af876 100644 --- a/sdk/src/v0.7/types/liquidation.ts +++ b/sdk/src/v0.7/types/liquidation.ts @@ -80,6 +80,59 @@ export type Liquidation = { }, ]; }, + { + 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"; + }; + }, + ]; + }, ]; accounts: [ { @@ -247,6 +300,22 @@ export type Liquidation = { ]; }; }, + { + name: "SetRefundRecordArgs"; + type: { + kind: "struct"; + fields: [ + { + name: "baseAssigned"; + type: "u64"; + }, + { + name: "quoteRefundable"; + type: "u64"; + }, + ]; + }; + }, ]; events: [ { @@ -561,6 +630,59 @@ export const IDL: Liquidation = { }, ], }, + { + 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", + }, + }, + ], + }, ], accounts: [ { @@ -728,6 +850,22 @@ export const IDL: Liquidation = { ], }, }, + { + name: "SetRefundRecordArgs", + type: { + kind: "struct", + fields: [ + { + name: "baseAssigned", + type: "u64", + }, + { + name: "quoteRefundable", + type: "u64", + }, + ], + }, + }, ], events: [ { From 16c3af3864497ac92ad112d4df89497e1401ae76 Mon Sep 17 00:00:00 2001 From: Pileks Date: Wed, 4 Mar 2026 20:08:28 +0100 Subject: [PATCH 08/35] set refund record sdk --- sdk/src/v0.7/LiquidationClient.ts | 47 +++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/sdk/src/v0.7/LiquidationClient.ts b/sdk/src/v0.7/LiquidationClient.ts index 2df15f448..22158dffc 100644 --- a/sdk/src/v0.7/LiquidationClient.ts +++ b/sdk/src/v0.7/LiquidationClient.ts @@ -1,4 +1,5 @@ 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, @@ -150,6 +151,52 @@ export class LiquidationClient { }); } + 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, + }); + } + + async getRefundRecord({ + liquidation, + recipient, + }: { + liquidation: PublicKey; + recipient: PublicKey; + }): Promise { + const refundRecord = this.getRefundRecordAddress({ + liquidation, + recipient, + }); + return this.fetchRefundRecord(refundRecord); + } + public getLiquidationAddress({ baseMint, quoteMint, From 4aa2de0c15ab812cba54a6c218fa1a6bfd36db03 Mon Sep 17 00:00:00 2001 From: Pileks Date: Wed, 4 Mar 2026 20:36:33 +0100 Subject: [PATCH 09/35] set refund record tests --- programs/liquidation/src/error.rs | 2 + .../src/instructions/set_refund_record.rs | 2 +- sdk/src/v0.7/types/liquidation.ts | 10 + tests/liquidation/main.test.ts | 5 +- .../liquidation/unit/setRefundRecord.test.ts | 333 ++++++++++++++++++ 5 files changed, 349 insertions(+), 3 deletions(-) create mode 100644 tests/liquidation/unit/setRefundRecord.test.ts diff --git a/programs/liquidation/src/error.rs b/programs/liquidation/src/error.rs index 041c00a74..d9ad3ac6b 100644 --- a/programs/liquidation/src/error.rs +++ b/programs/liquidation/src/error.rs @@ -20,4 +20,6 @@ pub enum LiquidationError { NothingToRefund, #[msg("Invalid allocation")] InvalidAllocation, + #[msg("Invalid authority")] + InvalidAuthority, } diff --git a/programs/liquidation/src/instructions/set_refund_record.rs b/programs/liquidation/src/instructions/set_refund_record.rs index a2e782909..663e72614 100644 --- a/programs/liquidation/src/instructions/set_refund_record.rs +++ b/programs/liquidation/src/instructions/set_refund_record.rs @@ -24,7 +24,7 @@ pub struct SetRefundRecord<'info> { #[account( mut, - has_one = record_authority, + constraint = liquidation.record_authority == record_authority.key() @ LiquidationError::InvalidAuthority, )] pub liquidation: Account<'info, Liquidation>, diff --git a/sdk/src/v0.7/types/liquidation.ts b/sdk/src/v0.7/types/liquidation.ts index e699af876..655be1409 100644 --- a/sdk/src/v0.7/types/liquidation.ts +++ b/sdk/src/v0.7/types/liquidation.ts @@ -545,6 +545,11 @@ export type Liquidation = { name: "InvalidAllocation"; msg: "Invalid allocation"; }, + { + code: 6009; + name: "InvalidAuthority"; + msg: "Invalid authority"; + }, ]; }; @@ -1095,5 +1100,10 @@ export const IDL: Liquidation = { name: "InvalidAllocation", msg: "Invalid allocation", }, + { + code: 6009, + name: "InvalidAuthority", + msg: "Invalid authority", + }, ], }; diff --git a/tests/liquidation/main.test.ts b/tests/liquidation/main.test.ts index d2639e39e..92ad6834f 100644 --- a/tests/liquidation/main.test.ts +++ b/tests/liquidation/main.test.ts @@ -1,6 +1,7 @@ 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"; export default function suite() { before(async function () { @@ -10,8 +11,8 @@ export default function suite() { }); }); - describe.only("#initialize_liquidation", initializeLiquidation); - describe("#set_refund_record", function () {}); + describe("#initialize_liquidation", initializeLiquidation); + describe.only("#set_refund_record", setRefundRecord); describe("#activate_liquidation", function () {}); describe("#refund", function () {}); describe("#withdraw_remaining_quote", function () {}); diff --git a/tests/liquidation/unit/setRefundRecord.test.ts b/tests/liquidation/unit/setRefundRecord.test.ts new file mode 100644 index 000000000..fbbc845de --- /dev/null +++ b/tests/liquidation/unit/setRefundRecord.test.ts @@ -0,0 +1,333 @@ +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 { LIQUIDATION_PROGRAM_ID } from "@metadaoproject/futarchy/v0.7"; +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 () { + // Directly flip is_refunding to true by modifying account data + const accountInfo = await this.banksClient.getAccount(liquidation); + const data = Buffer.from(accountInfo.data); + // is_refunding is at offset 220: + // 8 (discriminator) + 32*5 (pubkeys) + 8*5 (u64s) + 4 (u32) + 8 (u64) = 220 + data[220] = 1; + this.context.setAccount(liquidation, { + data, + executable: false, + owner: LIQUIDATION_PROGRAM_ID, + lamports: accountInfo.lamports, + }); + + const recipient = 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: recipient.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]); + }); +} From de606ce13c029360816b8571c081eaf2d600da31 Mon Sep 17 00:00:00 2001 From: Pileks Date: Wed, 4 Mar 2026 21:28:43 +0100 Subject: [PATCH 10/35] activate liquidation ix --- programs/liquidation/src/error.rs | 2 + .../src/instructions/activate_liquidation.rs | 101 +++++++++++++++++ programs/liquidation/src/instructions/mod.rs | 2 + programs/liquidation/src/lib.rs | 5 + sdk/src/v0.7/types/liquidation.ts | 102 ++++++++++++++++++ 5 files changed, 212 insertions(+) create mode 100644 programs/liquidation/src/instructions/activate_liquidation.rs diff --git a/programs/liquidation/src/error.rs b/programs/liquidation/src/error.rs index d9ad3ac6b..e920c4877 100644 --- a/programs/liquidation/src/error.rs +++ b/programs/liquidation/src/error.rs @@ -22,4 +22,6 @@ pub enum LiquidationError { InvalidAllocation, #[msg("Invalid authority")] InvalidAuthority, + #[msg("Invalid mint")] + InvalidMint, } diff --git a/programs/liquidation/src/instructions/activate_liquidation.rs b/programs/liquidation/src/instructions/activate_liquidation.rs new file mode 100644 index 000000000..d13240a78 --- /dev/null +++ b/programs/liquidation/src/instructions/activate_liquidation.rs @@ -0,0 +1,101 @@ +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, + )] + 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>, + + #[account( + constraint = quote_mint.key() == liquidation.quote_mint @ LiquidationError::InvalidMint, + )] + 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_quote_refundable, + 0, + LiquidationError::NothingToFund + ); + + require_gt!( + self.liquidation.total_base_assigned, + 0, + LiquidationError::NoBaseAssigned + ); + + 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/mod.rs b/programs/liquidation/src/instructions/mod.rs index 29176129a..544f9169d 100644 --- a/programs/liquidation/src/instructions/mod.rs +++ b/programs/liquidation/src/instructions/mod.rs @@ -1,5 +1,7 @@ +pub mod activate_liquidation; pub mod initialize_liquidation; pub mod set_refund_record; +pub use activate_liquidation::*; pub use initialize_liquidation::*; pub use set_refund_record::*; diff --git a/programs/liquidation/src/lib.rs b/programs/liquidation/src/lib.rs index 65ff5fcbb..ef9989320 100644 --- a/programs/liquidation/src/lib.rs +++ b/programs/liquidation/src/lib.rs @@ -43,4 +43,9 @@ pub mod liquidation { ) -> Result<()> { SetRefundRecord::handle(ctx, args) } + + #[access_control(ctx.accounts.validate())] + pub fn activate_liquidation(ctx: Context) -> Result<()> { + ActivateLiquidation::handle(ctx) + } } diff --git a/sdk/src/v0.7/types/liquidation.ts b/sdk/src/v0.7/types/liquidation.ts index 655be1409..85950bf4f 100644 --- a/sdk/src/v0.7/types/liquidation.ts +++ b/sdk/src/v0.7/types/liquidation.ts @@ -133,6 +133,52 @@ export type Liquidation = { }, ]; }, + { + 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: []; + }, ]; accounts: [ { @@ -550,6 +596,11 @@ export type Liquidation = { name: "InvalidAuthority"; msg: "Invalid authority"; }, + { + code: 6010; + name: "InvalidMint"; + msg: "Invalid mint"; + }, ]; }; @@ -688,6 +739,52 @@ export const IDL: Liquidation = { }, ], }, + { + 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: [], + }, ], accounts: [ { @@ -1105,5 +1202,10 @@ export const IDL: Liquidation = { name: "InvalidAuthority", msg: "Invalid authority", }, + { + code: 6010, + name: "InvalidMint", + msg: "Invalid mint", + }, ], }; From 2f3ef7522c292f6054f237766474737b17dac8c3 Mon Sep 17 00:00:00 2001 From: Pileks Date: Wed, 4 Mar 2026 21:32:27 +0100 Subject: [PATCH 11/35] activate liquidation sdk --- sdk/src/v0.7/LiquidationClient.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/sdk/src/v0.7/LiquidationClient.ts b/sdk/src/v0.7/LiquidationClient.ts index 22158dffc..21a64fe90 100644 --- a/sdk/src/v0.7/LiquidationClient.ts +++ b/sdk/src/v0.7/LiquidationClient.ts @@ -183,6 +183,33 @@ export class LiquidationClient { }); } + activateLiquidationIx({ + liquidationAuthority, + liquidation, + liquidationAuthorityQuoteAccount, + quoteMint, + }: { + liquidationAuthority: PublicKey; + liquidation: PublicKey; + liquidationAuthorityQuoteAccount: PublicKey; + quoteMint: PublicKey; + }) { + const liquidationQuoteVault = getAssociatedTokenAddressSync( + quoteMint, + liquidation, + true, + ); + + return this.liquidationProgram.methods.activateLiquidation().accounts({ + liquidationAuthority, + liquidation, + liquidationAuthorityQuoteAccount, + liquidationQuoteVault, + quoteMint, + tokenProgram: TOKEN_PROGRAM_ID, + }); + } + async getRefundRecord({ liquidation, recipient, From c274d711cde517b82b75ba40be58395251c1ca4e Mon Sep 17 00:00:00 2001 From: Pileks Date: Wed, 4 Mar 2026 21:52:08 +0100 Subject: [PATCH 12/35] activate liquidation tests, additional test fixes --- .../src/instructions/activate_liquidation.rs | 8 +- tests/liquidation/main.test.ts | 5 +- .../unit/activateLiquidation.test.ts | 261 ++++++++++++++++++ .../liquidation/unit/setRefundRecord.test.ts | 55 +++- tests/liquidation/utils.ts | 59 +++- 5 files changed, 366 insertions(+), 22 deletions(-) create mode 100644 tests/liquidation/unit/activateLiquidation.test.ts diff --git a/programs/liquidation/src/instructions/activate_liquidation.rs b/programs/liquidation/src/instructions/activate_liquidation.rs index d13240a78..2682d8ff3 100644 --- a/programs/liquidation/src/instructions/activate_liquidation.rs +++ b/programs/liquidation/src/instructions/activate_liquidation.rs @@ -48,15 +48,15 @@ impl ActivateLiquidation<'_> { ); require_gt!( - self.liquidation.total_quote_refundable, + self.liquidation.total_base_assigned, 0, - LiquidationError::NothingToFund + LiquidationError::NoBaseAssigned ); require_gt!( - self.liquidation.total_base_assigned, + self.liquidation.total_quote_refundable, 0, - LiquidationError::NoBaseAssigned + LiquidationError::NothingToFund ); Ok(()) diff --git a/tests/liquidation/main.test.ts b/tests/liquidation/main.test.ts index 92ad6834f..d7b4301ed 100644 --- a/tests/liquidation/main.test.ts +++ b/tests/liquidation/main.test.ts @@ -2,6 +2,7 @@ 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"; export default function suite() { before(async function () { @@ -12,8 +13,8 @@ export default function suite() { }); describe("#initialize_liquidation", initializeLiquidation); - describe.only("#set_refund_record", setRefundRecord); - describe("#activate_liquidation", function () {}); + describe("#set_refund_record", setRefundRecord); + describe("#activate_liquidation", activateLiquidation); describe("#refund", function () {}); describe("#withdraw_remaining_quote", function () {}); } diff --git a/tests/liquidation/unit/activateLiquidation.test.ts b/tests/liquidation/unit/activateLiquidation.test.ts new file mode 100644 index 000000000..cba0f9dd8 --- /dev/null +++ b/tests/liquidation/unit/activateLiquidation.test.ts @@ -0,0 +1,261 @@ +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; + let authorityQuoteAccount: 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 + ); + + authorityQuoteAccount = token.getAssociatedTokenAddressSync( + quoteMint, + liquidationAuthority.publicKey, + ); + }); + + it("successfully activates liquidation", async function () { + await liquidationClient + .activateLiquidationIx({ + liquidationAuthority: liquidationAuthority.publicKey, + liquidation, + liquidationAuthorityQuoteAccount: authorityQuoteAccount, + 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 wrongAuthorityQuoteAccount = token.getAssociatedTokenAddressSync( + quoteMint, + wrongAuthority.publicKey, + ); + + const callbacks = expectError( + "InvalidAuthority", + "Should have thrown InvalidAuthority error", + ); + + await liquidationClient + .activateLiquidationIx({ + liquidationAuthority: wrongAuthority.publicKey, + liquidation, + liquidationAuthorityQuoteAccount: wrongAuthorityQuoteAccount, + quoteMint, + }) + .signers([wrongAuthority]) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("throws error when already activated", async function () { + await liquidationClient + .activateLiquidationIx({ + liquidationAuthority: liquidationAuthority.publicKey, + liquidation, + liquidationAuthorityQuoteAccount: authorityQuoteAccount, + quoteMint, + }) + .signers([liquidationAuthority]) + .rpc(); + + // Try to activate again + const callbacks = expectError( + "AlreadyActivated", + "Should have thrown AlreadyActivated error", + ); + + await liquidationClient + .activateLiquidationIx({ + liquidationAuthority: liquidationAuthority.publicKey, + liquidation, + liquidationAuthorityQuoteAccount: authorityQuoteAccount, + 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 zeroAuthorityQuoteAccount = token.getAssociatedTokenAddressSync( + result.quoteMint, + result.liquidationAuthority.publicKey, + ); + + const callbacks = expectError( + "NoBaseAssigned", + "Should have thrown NoBaseAssigned error", + ); + + await liquidationClient + .activateLiquidationIx({ + liquidationAuthority: result.liquidationAuthority.publicKey, + liquidation: result.liquidation, + liquidationAuthorityQuoteAccount: zeroAuthorityQuoteAccount, + 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 zeroAuthorityQuoteAccount = token.getAssociatedTokenAddressSync( + result.quoteMint, + result.liquidationAuthority.publicKey, + ); + + const callbacks = expectError( + "NothingToFund", + "Should have thrown NothingToFund error", + ); + + await liquidationClient + .activateLiquidationIx({ + liquidationAuthority: result.liquidationAuthority.publicKey, + liquidation: result.liquidation, + liquidationAuthorityQuoteAccount: zeroAuthorityQuoteAccount, + 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, + liquidationAuthorityQuoteAccount: authorityQuoteAccount, + 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/setRefundRecord.test.ts b/tests/liquidation/unit/setRefundRecord.test.ts index fbbc845de..56b42e7b8 100644 --- a/tests/liquidation/unit/setRefundRecord.test.ts +++ b/tests/liquidation/unit/setRefundRecord.test.ts @@ -4,8 +4,8 @@ import { assert } from "chai"; import { expectError } from "../../utils.js"; import { setupLiquidation } from "../utils.js"; import BN from "bn.js"; -import { LIQUIDATION_PROGRAM_ID } from "@metadaoproject/futarchy/v0.7"; import { ComputeBudgetProgram } from "@solana/web3.js"; +import { getAssociatedTokenAddressSync } from "@solana/spl-token"; export default function suite() { let liquidationClient: LiquidationClient; @@ -276,21 +276,46 @@ export default function suite() { }); it("throws error when liquidation is already activated", async function () { - // Directly flip is_refunding to true by modifying account data - const accountInfo = await this.banksClient.getAccount(liquidation); - const data = Buffer.from(accountInfo.data); - // is_refunding is at offset 220: - // 8 (discriminator) + 32*5 (pubkeys) + 8*5 (u64s) + 4 (u32) + 8 (u64) = 220 - data[220] = 1; - this.context.setAccount(liquidation, { - data, - executable: false, - owner: LIQUIDATION_PROGRAM_ID, - lamports: accountInfo.lamports, - }); - 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, + ); + + const authorityQuoteAccount = getAssociatedTokenAddressSync( + quoteMint, + liquidationAuthority.publicKey, + ); + + await liquidationClient + .activateLiquidationIx({ + liquidationAuthority: liquidationAuthority.publicKey, + liquidation, + liquidationAuthorityQuoteAccount: authorityQuoteAccount, + 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", @@ -302,7 +327,7 @@ export default function suite() { quoteRefundable: new BN(50_000_000), recordAuthority: recordAuthority.publicKey, liquidation, - recipient: recipient.publicKey, + recipient: recipient2.publicKey, }) .signers([recordAuthority]) .rpc() diff --git a/tests/liquidation/utils.ts b/tests/liquidation/utils.ts index e6e14685f..a0e74f586 100644 --- a/tests/liquidation/utils.ts +++ b/tests/liquidation/utils.ts @@ -1,5 +1,6 @@ -import { PublicKey, Keypair } from "@solana/web3.js"; +import { PublicKey, Keypair, ComputeBudgetProgram } 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; @@ -35,3 +36,59 @@ export async function setupLiquidation(ctx: Mocha.Context): Promise<{ 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; +} From a03c56e638ecb723d7aec9bff1d5361ae42e1ef4 Mon Sep 17 00:00:00 2001 From: Pileks Date: Wed, 4 Mar 2026 22:09:05 +0100 Subject: [PATCH 13/35] refund ix --- programs/liquidation/src/instructions/mod.rs | 2 + .../liquidation/src/instructions/refund.rs | 169 ++++++++++++++++++ programs/liquidation/src/lib.rs | 5 + sdk/src/v0.7/types/liquidation.ts | 142 +++++++++++++++ 4 files changed, 318 insertions(+) create mode 100644 programs/liquidation/src/instructions/refund.rs diff --git a/programs/liquidation/src/instructions/mod.rs b/programs/liquidation/src/instructions/mod.rs index 544f9169d..d27f33d87 100644 --- a/programs/liquidation/src/instructions/mod.rs +++ b/programs/liquidation/src/instructions/mod.rs @@ -1,7 +1,9 @@ pub mod activate_liquidation; pub mod initialize_liquidation; +pub mod refund; pub mod set_refund_record; pub use activate_liquidation::*; pub use initialize_liquidation::*; +pub use refund::*; pub use set_refund_record::*; diff --git a/programs/liquidation/src/instructions/refund.rs b/programs/liquidation/src/instructions/refund.rs new file mode 100644 index 000000000..e071a7389 --- /dev/null +++ b/programs/liquidation/src/instructions/refund.rs @@ -0,0 +1,169 @@ +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)] + 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, + constraint = base_mint.key() == liquidation.base_mint @ LiquidationError::InvalidMint, + )] + pub base_mint: Account<'info, Mint>, + + #[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( + constraint = quote_mint.key() == liquidation.quote_mint @ LiquidationError::InvalidMint, + )] + 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!( + clock.unix_timestamp + <= self.liquidation.started_at + self.liquidation.duration_seconds as i64, + 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 = &ctx.accounts.refund_record; + + // 1. 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); + + // 2. 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, + )?; + + // 3-4. Update base_burned counters + let refund_record = &mut ctx.accounts.refund_record; + let liquidation = &mut ctx.accounts.liquidation; + + refund_record.base_burned += effective_burn; + liquidation.total_base_burned += effective_burn; + + // 5. 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; + + // 6. Compute transfer amount + let quote_transfer = quote_owed - refund_record.quote_refunded; + + // 7. 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, + )?; + + // 8-9. Update quote_refunded counters + refund_record.quote_refunded += quote_transfer; + liquidation.total_quote_refunded += quote_transfer; + + // 10. Emit event + liquidation.seq_num += 1; + + emit_cpi!(RefundEvent { + common: CommonFields::new(&clock, liquidation.seq_num), + liquidation: liquidation.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/lib.rs b/programs/liquidation/src/lib.rs index ef9989320..d553ae388 100644 --- a/programs/liquidation/src/lib.rs +++ b/programs/liquidation/src/lib.rs @@ -48,4 +48,9 @@ pub mod liquidation { pub fn activate_liquidation(ctx: Context) -> Result<()> { ActivateLiquidation::handle(ctx) } + + #[access_control(ctx.accounts.validate())] + pub fn refund(ctx: Context) -> Result<()> { + Refund::handle(ctx) + } } diff --git a/sdk/src/v0.7/types/liquidation.ts b/sdk/src/v0.7/types/liquidation.ts index 85950bf4f..28684cc16 100644 --- a/sdk/src/v0.7/types/liquidation.ts +++ b/sdk/src/v0.7/types/liquidation.ts @@ -179,6 +179,77 @@ export type Liquidation = { ]; args: []; }, + { + name: "refund"; + accounts: [ + { + name: "recipient"; + isMut: true; + isSigner: true; + }, + { + name: "liquidation"; + isMut: true; + isSigner: false; + }, + { + name: "refundRecord"; + isMut: true; + isSigner: false; + }, + { + name: "baseMint"; + isMut: true; + isSigner: false; + }, + { + name: "recipientBaseAccount"; + isMut: true; + isSigner: false; + }, + { + name: "liquidationQuoteVault"; + isMut: true; + isSigner: false; + }, + { + name: "recipientQuoteAccount"; + 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: []; + }, ]; accounts: [ { @@ -785,6 +856,77 @@ export const IDL: Liquidation = { ], args: [], }, + { + name: "refund", + accounts: [ + { + name: "recipient", + isMut: true, + isSigner: true, + }, + { + name: "liquidation", + isMut: true, + isSigner: false, + }, + { + name: "refundRecord", + isMut: true, + isSigner: false, + }, + { + name: "baseMint", + isMut: true, + isSigner: false, + }, + { + name: "recipientBaseAccount", + isMut: true, + isSigner: false, + }, + { + name: "liquidationQuoteVault", + isMut: true, + isSigner: false, + }, + { + name: "recipientQuoteAccount", + 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: [], + }, ], accounts: [ { From 1e4d27724f1652c2fee6b21f670c4387fedcb6c1 Mon Sep 17 00:00:00 2001 From: Pileks Date: Wed, 4 Mar 2026 22:14:57 +0100 Subject: [PATCH 14/35] refund sdk --- .../liquidation/src/instructions/refund.rs | 4 +- sdk/src/v0.7/LiquidationClient.ts | 45 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/programs/liquidation/src/instructions/refund.rs b/programs/liquidation/src/instructions/refund.rs index e071a7389..b4e7aff5d 100644 --- a/programs/liquidation/src/instructions/refund.rs +++ b/programs/liquidation/src/instructions/refund.rs @@ -108,7 +108,7 @@ impl Refund<'_> { effective_burn, )?; - // 3-4. Update base_burned counters + // 3-4. Update base_burned totals let refund_record = &mut ctx.accounts.refund_record; let liquidation = &mut ctx.accounts.liquidation; @@ -145,7 +145,7 @@ impl Refund<'_> { quote_transfer, )?; - // 8-9. Update quote_refunded counters + // 8-9. Update quote_refunded totals refund_record.quote_refunded += quote_transfer; liquidation.total_quote_refunded += quote_transfer; diff --git a/sdk/src/v0.7/LiquidationClient.ts b/sdk/src/v0.7/LiquidationClient.ts index 21a64fe90..b4a4794c7 100644 --- a/sdk/src/v0.7/LiquidationClient.ts +++ b/sdk/src/v0.7/LiquidationClient.ts @@ -210,6 +210,51 @@ export class LiquidationClient { }); } + refundIx({ + recipient, + liquidation, + baseMint, + recipientBaseAccount, + quoteMint, + }: { + recipient: PublicKey; + liquidation: PublicKey; + baseMint: PublicKey; + recipientBaseAccount: PublicKey; + quoteMint: PublicKey; + }) { + const refundRecord = this.getRefundRecordAddress({ + liquidation, + recipient, + }); + + const liquidationQuoteVault = getAssociatedTokenAddressSync( + quoteMint, + liquidation, + true, + ); + + const recipientQuoteAccount = getAssociatedTokenAddressSync( + quoteMint, + recipient, + true, + ); + + return this.liquidationProgram.methods.refund().accounts({ + recipient, + liquidation, + refundRecord, + baseMint, + recipientBaseAccount, + liquidationQuoteVault, + recipientQuoteAccount, + quoteMint, + tokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, + }); + } + async getRefundRecord({ liquidation, recipient, From c94db9c79a851df783675f8eda483b6a6e986237 Mon Sep 17 00:00:00 2001 From: Pileks Date: Wed, 4 Mar 2026 22:42:21 +0100 Subject: [PATCH 15/35] refund tests --- sdk/src/v0.7/LiquidationClient.ts | 8 +- tests/liquidation/main.test.ts | 3 +- tests/liquidation/unit/refund.test.ts | 419 ++++++++++++++++++++++++++ tests/liquidation/utils.ts | 64 +++- 4 files changed, 490 insertions(+), 4 deletions(-) create mode 100644 tests/liquidation/unit/refund.test.ts diff --git a/sdk/src/v0.7/LiquidationClient.ts b/sdk/src/v0.7/LiquidationClient.ts index b4a4794c7..ab55c5cd4 100644 --- a/sdk/src/v0.7/LiquidationClient.ts +++ b/sdk/src/v0.7/LiquidationClient.ts @@ -220,7 +220,7 @@ export class LiquidationClient { recipient: PublicKey; liquidation: PublicKey; baseMint: PublicKey; - recipientBaseAccount: PublicKey; + recipientBaseAccount?: PublicKey; quoteMint: PublicKey; }) { const refundRecord = this.getRefundRecordAddress({ @@ -228,6 +228,10 @@ export class LiquidationClient { recipient, }); + const resolvedRecipientBaseAccount = + recipientBaseAccount ?? + getAssociatedTokenAddressSync(baseMint, recipient, true); + const liquidationQuoteVault = getAssociatedTokenAddressSync( quoteMint, liquidation, @@ -245,7 +249,7 @@ export class LiquidationClient { liquidation, refundRecord, baseMint, - recipientBaseAccount, + recipientBaseAccount: resolvedRecipientBaseAccount, liquidationQuoteVault, recipientQuoteAccount, quoteMint, diff --git a/tests/liquidation/main.test.ts b/tests/liquidation/main.test.ts index d7b4301ed..afae781c3 100644 --- a/tests/liquidation/main.test.ts +++ b/tests/liquidation/main.test.ts @@ -3,6 +3,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"; export default function suite() { before(async function () { @@ -15,6 +16,6 @@ export default function suite() { describe("#initialize_liquidation", initializeLiquidation); describe("#set_refund_record", setRefundRecord); describe("#activate_liquidation", activateLiquidation); - describe("#refund", function () {}); + describe("#refund", refund); describe("#withdraw_remaining_quote", function () {}); } 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/utils.ts b/tests/liquidation/utils.ts index a0e74f586..23042d22d 100644 --- a/tests/liquidation/utils.ts +++ b/tests/liquidation/utils.ts @@ -1,6 +1,12 @@ -import { PublicKey, Keypair, ComputeBudgetProgram } from "@solana/web3.js"; +import { + PublicKey, + Keypair, + ComputeBudgetProgram, + SystemProgram, +} from "@solana/web3.js"; import { LiquidationClient } from "@metadaoproject/futarchy/v0.7"; import BN from "bn.js"; +import * as token from "@solana/spl-token"; export async function setupLiquidation(ctx: Mocha.Context): Promise<{ baseMint: PublicKey; @@ -92,3 +98,59 @@ export async function setupLiquidationWithRefundRecords( 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(), + ); + + const authorityQuoteAccount = token.getAssociatedTokenAddressSync( + result.quoteMint, + result.liquidationAuthority.publicKey, + ); + + await liquidationClient + .activateLiquidationIx({ + liquidationAuthority: result.liquidationAuthority.publicKey, + liquidation: result.liquidation, + liquidationAuthorityQuoteAccount: authorityQuoteAccount, + quoteMint: result.quoteMint, + }) + .signers([result.liquidationAuthority]) + .rpc(); + + return result; +} From 6a6940be43dc3c2a05adce7a69d12e26a2462341 Mon Sep 17 00:00:00 2001 From: Pileks Date: Wed, 4 Mar 2026 22:46:07 +0100 Subject: [PATCH 16/35] remove comment numbering. --- programs/liquidation/src/instructions/refund.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/programs/liquidation/src/instructions/refund.rs b/programs/liquidation/src/instructions/refund.rs index b4e7aff5d..7e72ef41c 100644 --- a/programs/liquidation/src/instructions/refund.rs +++ b/programs/liquidation/src/instructions/refund.rs @@ -91,11 +91,11 @@ impl Refund<'_> { let clock = Clock::get()?; let refund_record = &ctx.accounts.refund_record; - // 1. Compute effective burn + // 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); - // 2. Burn base tokens from recipient + // Burn base tokens from recipient token::burn( CpiContext::new( ctx.accounts.token_program.to_account_info(), @@ -108,22 +108,22 @@ impl Refund<'_> { effective_burn, )?; - // 3-4. Update base_burned totals + // Update base_burned totals let refund_record = &mut ctx.accounts.refund_record; let liquidation = &mut ctx.accounts.liquidation; refund_record.base_burned += effective_burn; liquidation.total_base_burned += effective_burn; - // 5. Compute quote owed + // 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; - // 6. Compute transfer amount + // Compute transfer amount let quote_transfer = quote_owed - refund_record.quote_refunded; - // 7. Transfer quote tokens from vault to recipient (PDA-signed) + // Transfer quote tokens from vault to recipient (PDA-signed) let signer_seeds: &[&[&[u8]]] = &[&[ SEED_LIQUIDATION, liquidation.base_mint.as_ref(), @@ -145,11 +145,11 @@ impl Refund<'_> { quote_transfer, )?; - // 8-9. Update quote_refunded totals + // Update quote_refunded totals refund_record.quote_refunded += quote_transfer; liquidation.total_quote_refunded += quote_transfer; - // 10. Emit event + // Emit event liquidation.seq_num += 1; emit_cpi!(RefundEvent { From 69256c564ece8c4da08a632a4c7d451d9f6a186a Mon Sep 17 00:00:00 2001 From: Pileks Date: Wed, 4 Mar 2026 22:55:48 +0100 Subject: [PATCH 17/35] withdraw remaining quote ix --- programs/liquidation/src/instructions/mod.rs | 2 + .../instructions/withdraw_remaining_quote.rs | 100 ++++++++++++++++++ programs/liquidation/src/lib.rs | 5 + sdk/src/v0.7/types/liquidation.ts | 92 ++++++++++++++++ 4 files changed, 199 insertions(+) create mode 100644 programs/liquidation/src/instructions/withdraw_remaining_quote.rs diff --git a/programs/liquidation/src/instructions/mod.rs b/programs/liquidation/src/instructions/mod.rs index d27f33d87..d3376a652 100644 --- a/programs/liquidation/src/instructions/mod.rs +++ b/programs/liquidation/src/instructions/mod.rs @@ -2,8 +2,10 @@ 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/withdraw_remaining_quote.rs b/programs/liquidation/src/instructions/withdraw_remaining_quote.rs new file mode 100644 index 000000000..d1f6bc7f6 --- /dev/null +++ b/programs/liquidation/src/instructions/withdraw_remaining_quote.rs @@ -0,0 +1,100 @@ +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, + )] + 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!( + 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 index d553ae388..3b9ab86b5 100644 --- a/programs/liquidation/src/lib.rs +++ b/programs/liquidation/src/lib.rs @@ -53,4 +53,9 @@ pub mod liquidation { 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/sdk/src/v0.7/types/liquidation.ts b/sdk/src/v0.7/types/liquidation.ts index 28684cc16..7e62a5b49 100644 --- a/sdk/src/v0.7/types/liquidation.ts +++ b/sdk/src/v0.7/types/liquidation.ts @@ -250,6 +250,52 @@ export type Liquidation = { ]; 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: [ { @@ -927,6 +973,52 @@ export const IDL: Liquidation = { ], 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: [ { From fcbb0a01aad616bf851b1768f41c3de2921d9de5 Mon Sep 17 00:00:00 2001 From: Pileks Date: Wed, 4 Mar 2026 23:02:21 +0100 Subject: [PATCH 18/35] minor cleanup --- .../src/instructions/activate_liquidation.rs | 4 +--- programs/liquidation/src/instructions/refund.rs | 17 +++++++---------- sdk/src/v0.7/types/liquidation.ts | 16 ++++++++-------- 3 files changed, 16 insertions(+), 21 deletions(-) diff --git a/programs/liquidation/src/instructions/activate_liquidation.rs b/programs/liquidation/src/instructions/activate_liquidation.rs index 2682d8ff3..ccf191bcc 100644 --- a/programs/liquidation/src/instructions/activate_liquidation.rs +++ b/programs/liquidation/src/instructions/activate_liquidation.rs @@ -15,6 +15,7 @@ pub struct ActivateLiquidation<'info> { #[account( mut, has_one = liquidation_authority @ LiquidationError::InvalidAuthority, + has_one = quote_mint @ LiquidationError::InvalidMint, )] pub liquidation: Account<'info, Liquidation>, @@ -32,9 +33,6 @@ pub struct ActivateLiquidation<'info> { )] pub liquidation_quote_vault: Account<'info, TokenAccount>, - #[account( - constraint = quote_mint.key() == liquidation.quote_mint @ LiquidationError::InvalidMint, - )] pub quote_mint: Account<'info, Mint>, pub token_program: Program<'info, Token>, diff --git a/programs/liquidation/src/instructions/refund.rs b/programs/liquidation/src/instructions/refund.rs index 7e72ef41c..c56ba78ff 100644 --- a/programs/liquidation/src/instructions/refund.rs +++ b/programs/liquidation/src/instructions/refund.rs @@ -16,7 +16,11 @@ pub struct Refund<'info> { #[account(mut)] pub recipient: Signer<'info>, - #[account(mut)] + #[account( + mut, + has_one = base_mint @ LiquidationError::InvalidMint, + has_one = quote_mint @ LiquidationError::InvalidMint, + )] pub liquidation: Account<'info, Liquidation>, #[account( @@ -26,12 +30,6 @@ pub struct Refund<'info> { )] pub refund_record: Account<'info, RefundRecord>, - #[account( - mut, - constraint = base_mint.key() == liquidation.base_mint @ LiquidationError::InvalidMint, - )] - pub base_mint: Account<'info, Mint>, - #[account( mut, token::mint = base_mint, @@ -54,9 +52,8 @@ pub struct Refund<'info> { )] pub recipient_quote_account: Account<'info, TokenAccount>, - #[account( - constraint = quote_mint.key() == liquidation.quote_mint @ LiquidationError::InvalidMint, - )] + #[account(mut)] + pub base_mint: Account<'info, Mint>, pub quote_mint: Account<'info, Mint>, pub token_program: Program<'info, Token>, diff --git a/sdk/src/v0.7/types/liquidation.ts b/sdk/src/v0.7/types/liquidation.ts index 7e62a5b49..66a4460d5 100644 --- a/sdk/src/v0.7/types/liquidation.ts +++ b/sdk/src/v0.7/types/liquidation.ts @@ -198,22 +198,22 @@ export type Liquidation = { isSigner: false; }, { - name: "baseMint"; + name: "recipientBaseAccount"; isMut: true; isSigner: false; }, { - name: "recipientBaseAccount"; + name: "liquidationQuoteVault"; isMut: true; isSigner: false; }, { - name: "liquidationQuoteVault"; + name: "recipientQuoteAccount"; isMut: true; isSigner: false; }, { - name: "recipientQuoteAccount"; + name: "baseMint"; isMut: true; isSigner: false; }, @@ -921,22 +921,22 @@ export const IDL: Liquidation = { isSigner: false, }, { - name: "baseMint", + name: "recipientBaseAccount", isMut: true, isSigner: false, }, { - name: "recipientBaseAccount", + name: "liquidationQuoteVault", isMut: true, isSigner: false, }, { - name: "liquidationQuoteVault", + name: "recipientQuoteAccount", isMut: true, isSigner: false, }, { - name: "recipientQuoteAccount", + name: "baseMint", isMut: true, isSigner: false, }, From e75bb5664c9e6e91fac15361baaf4b6ee865dc9f Mon Sep 17 00:00:00 2001 From: Pileks Date: Wed, 4 Mar 2026 23:07:35 +0100 Subject: [PATCH 19/35] withdraw remaining quote sdk --- sdk/src/v0.7/LiquidationClient.ts | 32 +++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/sdk/src/v0.7/LiquidationClient.ts b/sdk/src/v0.7/LiquidationClient.ts index ab55c5cd4..040460f0b 100644 --- a/sdk/src/v0.7/LiquidationClient.ts +++ b/sdk/src/v0.7/LiquidationClient.ts @@ -259,6 +259,38 @@ export class LiquidationClient { }); } + 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, From 05e20b6c377a90e95ee6d233e79089552175f2d8 Mon Sep 17 00:00:00 2001 From: Pileks Date: Wed, 4 Mar 2026 23:12:10 +0100 Subject: [PATCH 20/35] withdraw remaining quote tests --- tests/liquidation/main.test.ts | 3 +- .../unit/withdrawRemainingQuote.test.ts | 328 ++++++++++++++++++ 2 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 tests/liquidation/unit/withdrawRemainingQuote.test.ts diff --git a/tests/liquidation/main.test.ts b/tests/liquidation/main.test.ts index afae781c3..f9b8b1062 100644 --- a/tests/liquidation/main.test.ts +++ b/tests/liquidation/main.test.ts @@ -4,6 +4,7 @@ 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 () { @@ -17,5 +18,5 @@ export default function suite() { describe("#set_refund_record", setRefundRecord); describe("#activate_liquidation", activateLiquidation); describe("#refund", refund); - describe("#withdraw_remaining_quote", function () {}); + describe.only("#withdraw_remaining_quote", withdrawRemainingQuote); } 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"); + }); +} From a0891f1a19de370787801aaa318abb12048906e8 Mon Sep 17 00:00:00 2001 From: Pileks Date: Wed, 4 Mar 2026 23:32:04 +0100 Subject: [PATCH 21/35] further cleanup --- sdk/src/v0.7/LiquidationClient.ts | 9 ++++-- tests/liquidation/main.test.ts | 2 +- .../unit/activateLiquidation.test.ts | 28 ------------------- .../liquidation/unit/setRefundRecord.test.ts | 7 ----- tests/liquidation/utils.ts | 7 ----- 5 files changed, 8 insertions(+), 45 deletions(-) diff --git a/sdk/src/v0.7/LiquidationClient.ts b/sdk/src/v0.7/LiquidationClient.ts index 040460f0b..1c0724330 100644 --- a/sdk/src/v0.7/LiquidationClient.ts +++ b/sdk/src/v0.7/LiquidationClient.ts @@ -191,7 +191,7 @@ export class LiquidationClient { }: { liquidationAuthority: PublicKey; liquidation: PublicKey; - liquidationAuthorityQuoteAccount: PublicKey; + liquidationAuthorityQuoteAccount?: PublicKey; quoteMint: PublicKey; }) { const liquidationQuoteVault = getAssociatedTokenAddressSync( @@ -200,10 +200,15 @@ export class LiquidationClient { true, ); + const resolvedLiquidationAuthorityQuoteAccount = + liquidationAuthorityQuoteAccount ?? + getAssociatedTokenAddressSync(quoteMint, liquidationAuthority, true); + return this.liquidationProgram.methods.activateLiquidation().accounts({ liquidationAuthority, liquidation, - liquidationAuthorityQuoteAccount, + liquidationAuthorityQuoteAccount: + resolvedLiquidationAuthorityQuoteAccount, liquidationQuoteVault, quoteMint, tokenProgram: TOKEN_PROGRAM_ID, diff --git a/tests/liquidation/main.test.ts b/tests/liquidation/main.test.ts index f9b8b1062..a0dabe85a 100644 --- a/tests/liquidation/main.test.ts +++ b/tests/liquidation/main.test.ts @@ -18,5 +18,5 @@ export default function suite() { describe("#set_refund_record", setRefundRecord); describe("#activate_liquidation", activateLiquidation); describe("#refund", refund); - describe.only("#withdraw_remaining_quote", withdrawRemainingQuote); + describe("#withdraw_remaining_quote", withdrawRemainingQuote); } diff --git a/tests/liquidation/unit/activateLiquidation.test.ts b/tests/liquidation/unit/activateLiquidation.test.ts index cba0f9dd8..2f8071ac5 100644 --- a/tests/liquidation/unit/activateLiquidation.test.ts +++ b/tests/liquidation/unit/activateLiquidation.test.ts @@ -14,7 +14,6 @@ export default function suite() { let recordAuthority: Keypair; let liquidationAuthority: Keypair; let liquidation: PublicKey; - let authorityQuoteAccount: PublicKey; before(async function () { liquidationClient = this.liquidation; @@ -51,11 +50,6 @@ export default function suite() { this.payer, 1_500_000_000, // 500 + 1000 ); - - authorityQuoteAccount = token.getAssociatedTokenAddressSync( - quoteMint, - liquidationAuthority.publicKey, - ); }); it("successfully activates liquidation", async function () { @@ -63,7 +57,6 @@ export default function suite() { .activateLiquidationIx({ liquidationAuthority: liquidationAuthority.publicKey, liquidation, - liquidationAuthorityQuoteAccount: authorityQuoteAccount, quoteMint, }) .signers([liquidationAuthority]) @@ -95,11 +88,6 @@ export default function suite() { 1_500_000_000, ); - const wrongAuthorityQuoteAccount = token.getAssociatedTokenAddressSync( - quoteMint, - wrongAuthority.publicKey, - ); - const callbacks = expectError( "InvalidAuthority", "Should have thrown InvalidAuthority error", @@ -109,7 +97,6 @@ export default function suite() { .activateLiquidationIx({ liquidationAuthority: wrongAuthority.publicKey, liquidation, - liquidationAuthorityQuoteAccount: wrongAuthorityQuoteAccount, quoteMint, }) .signers([wrongAuthority]) @@ -122,7 +109,6 @@ export default function suite() { .activateLiquidationIx({ liquidationAuthority: liquidationAuthority.publicKey, liquidation, - liquidationAuthorityQuoteAccount: authorityQuoteAccount, quoteMint, }) .signers([liquidationAuthority]) @@ -138,7 +124,6 @@ export default function suite() { .activateLiquidationIx({ liquidationAuthority: liquidationAuthority.publicKey, liquidation, - liquidationAuthorityQuoteAccount: authorityQuoteAccount, quoteMint, }) .postInstructions([ @@ -165,11 +150,6 @@ export default function suite() { result.liquidationAuthority.publicKey, ); - const zeroAuthorityQuoteAccount = token.getAssociatedTokenAddressSync( - result.quoteMint, - result.liquidationAuthority.publicKey, - ); - const callbacks = expectError( "NoBaseAssigned", "Should have thrown NoBaseAssigned error", @@ -179,7 +159,6 @@ export default function suite() { .activateLiquidationIx({ liquidationAuthority: result.liquidationAuthority.publicKey, liquidation: result.liquidation, - liquidationAuthorityQuoteAccount: zeroAuthorityQuoteAccount, quoteMint: result.quoteMint, }) .signers([result.liquidationAuthority]) @@ -203,11 +182,6 @@ export default function suite() { result.liquidationAuthority.publicKey, ); - const zeroAuthorityQuoteAccount = token.getAssociatedTokenAddressSync( - result.quoteMint, - result.liquidationAuthority.publicKey, - ); - const callbacks = expectError( "NothingToFund", "Should have thrown NothingToFund error", @@ -217,7 +191,6 @@ export default function suite() { .activateLiquidationIx({ liquidationAuthority: result.liquidationAuthority.publicKey, liquidation: result.liquidation, - liquidationAuthorityQuoteAccount: zeroAuthorityQuoteAccount, quoteMint: result.quoteMint, }) .signers([result.liquidationAuthority]) @@ -248,7 +221,6 @@ export default function suite() { .activateLiquidationIx({ liquidationAuthority: liquidationAuthority.publicKey, liquidation, - liquidationAuthorityQuoteAccount: authorityQuoteAccount, quoteMint, }) .signers([liquidationAuthority]) diff --git a/tests/liquidation/unit/setRefundRecord.test.ts b/tests/liquidation/unit/setRefundRecord.test.ts index 56b42e7b8..6d364c7c8 100644 --- a/tests/liquidation/unit/setRefundRecord.test.ts +++ b/tests/liquidation/unit/setRefundRecord.test.ts @@ -5,7 +5,6 @@ import { expectError } from "../../utils.js"; import { setupLiquidation } from "../utils.js"; import BN from "bn.js"; import { ComputeBudgetProgram } from "@solana/web3.js"; -import { getAssociatedTokenAddressSync } from "@solana/spl-token"; export default function suite() { let liquidationClient: LiquidationClient; @@ -298,16 +297,10 @@ export default function suite() { 50_000_000, ); - const authorityQuoteAccount = getAssociatedTokenAddressSync( - quoteMint, - liquidationAuthority.publicKey, - ); - await liquidationClient .activateLiquidationIx({ liquidationAuthority: liquidationAuthority.publicKey, liquidation, - liquidationAuthorityQuoteAccount: authorityQuoteAccount, quoteMint, }) .signers([liquidationAuthority]) diff --git a/tests/liquidation/utils.ts b/tests/liquidation/utils.ts index 23042d22d..7921ddfa8 100644 --- a/tests/liquidation/utils.ts +++ b/tests/liquidation/utils.ts @@ -6,7 +6,6 @@ import { } from "@solana/web3.js"; import { LiquidationClient } from "@metadaoproject/futarchy/v0.7"; import BN from "bn.js"; -import * as token from "@solana/spl-token"; export async function setupLiquidation(ctx: Mocha.Context): Promise<{ baseMint: PublicKey; @@ -137,16 +136,10 @@ export async function setupActivatedLiquidation( totalQuoteRefundable.toNumber(), ); - const authorityQuoteAccount = token.getAssociatedTokenAddressSync( - result.quoteMint, - result.liquidationAuthority.publicKey, - ); - await liquidationClient .activateLiquidationIx({ liquidationAuthority: result.liquidationAuthority.publicKey, liquidation: result.liquidation, - liquidationAuthorityQuoteAccount: authorityQuoteAccount, quoteMint: result.quoteMint, }) .signers([result.liquidationAuthority]) From b2a8b73b9d4c2567706cd2b2fbc14feba0a6636f Mon Sep 17 00:00:00 2001 From: Pileks Date: Mon, 9 Mar 2026 20:23:31 +0100 Subject: [PATCH 22/35] update sdk version --- sdk/package.json | 2 +- yarn.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/package.json b/sdk/package.json index 60d42aa6c..b7ad6f15c 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.0", "type": "module", "main": "dist/index.js", "module": "dist/index.js", diff --git a/yarn.lock b/yarn.lock index a06b93872..35cd219ae 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.0" dependencies: "@coral-xyz/anchor" "^0.29.0" "@metaplex-foundation/umi" "^0.9.2" From 01b12d8576220c0f2cdce9a74754bc2225855332 Mon Sep 17 00:00:00 2001 From: Pileks Date: Mon, 9 Mar 2026 22:43:26 +0100 Subject: [PATCH 23/35] add refund_record address to events --- programs/liquidation/src/events.rs | 2 ++ .../liquidation/src/instructions/refund.rs | 4 ++-- .../src/instructions/set_refund_record.rs | 1 + sdk/src/v0.7/types/liquidation.ts | 20 +++++++++++++++++++ 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/programs/liquidation/src/events.rs b/programs/liquidation/src/events.rs index b060e7bc3..d44a6fc5e 100644 --- a/programs/liquidation/src/events.rs +++ b/programs/liquidation/src/events.rs @@ -40,6 +40,7 @@ pub struct LiquidationActivatedEvent { pub struct RefundRecordSetEvent { pub common: CommonFields, pub liquidation: Pubkey, + pub refund_record: Pubkey, pub recipient: Pubkey, pub base_assigned: u64, pub quote_refundable: u64, @@ -49,6 +50,7 @@ pub struct RefundRecordSetEvent { pub struct RefundEvent { pub common: CommonFields, pub liquidation: Pubkey, + pub refund_record: Pubkey, pub recipient: Pubkey, pub base_burned: u64, pub quote_refunded: u64, diff --git a/programs/liquidation/src/instructions/refund.rs b/programs/liquidation/src/instructions/refund.rs index c56ba78ff..037bd8660 100644 --- a/programs/liquidation/src/instructions/refund.rs +++ b/programs/liquidation/src/instructions/refund.rs @@ -86,7 +86,7 @@ impl Refund<'_> { pub fn handle(ctx: Context) -> Result<()> { let clock = Clock::get()?; - let refund_record = &ctx.accounts.refund_record; + let refund_record = &mut ctx.accounts.refund_record; // Compute effective burn let remaining_burnable = refund_record.base_assigned - refund_record.base_burned; @@ -106,7 +106,6 @@ impl Refund<'_> { )?; // Update base_burned totals - let refund_record = &mut ctx.accounts.refund_record; let liquidation = &mut ctx.accounts.liquidation; refund_record.base_burned += effective_burn; @@ -152,6 +151,7 @@ impl Refund<'_> { 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, diff --git a/programs/liquidation/src/instructions/set_refund_record.rs b/programs/liquidation/src/instructions/set_refund_record.rs index 663e72614..bec03056b 100644 --- a/programs/liquidation/src/instructions/set_refund_record.rs +++ b/programs/liquidation/src/instructions/set_refund_record.rs @@ -103,6 +103,7 @@ impl SetRefundRecord<'_> { 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, diff --git a/sdk/src/v0.7/types/liquidation.ts b/sdk/src/v0.7/types/liquidation.ts index 66a4460d5..91002329d 100644 --- a/sdk/src/v0.7/types/liquidation.ts +++ b/sdk/src/v0.7/types/liquidation.ts @@ -565,6 +565,11 @@ export type Liquidation = { type: "publicKey"; index: false; }, + { + name: "refundRecord"; + type: "publicKey"; + index: false; + }, { name: "recipient"; type: "publicKey"; @@ -597,6 +602,11 @@ export type Liquidation = { type: "publicKey"; index: false; }, + { + name: "refundRecord"; + type: "publicKey"; + index: false; + }, { name: "recipient"; type: "publicKey"; @@ -1288,6 +1298,11 @@ export const IDL: Liquidation = { type: "publicKey", index: false, }, + { + name: "refundRecord", + type: "publicKey", + index: false, + }, { name: "recipient", type: "publicKey", @@ -1320,6 +1335,11 @@ export const IDL: Liquidation = { type: "publicKey", index: false, }, + { + name: "refundRecord", + type: "publicKey", + index: false, + }, { name: "recipient", type: "publicKey", From 3d6a2ae5857abbd4d73c1498657a884dd6878896 Mon Sep 17 00:00:00 2001 From: Pileks Date: Mon, 9 Mar 2026 22:48:46 +0100 Subject: [PATCH 24/35] bump SDK version --- sdk/package.json | 2 +- yarn.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/package.json b/sdk/package.json index b7ad6f15c..c0fe6d5fd 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@metadaoproject/futarchy", - "version": "0.7.1-alpha.0", + "version": "0.7.1-alpha.1", "type": "module", "main": "dist/index.js", "module": "dist/index.js", diff --git a/yarn.lock b/yarn.lock index 35cd219ae..690465431 100644 --- a/yarn.lock +++ b/yarn.lock @@ -975,7 +975,7 @@ "@jridgewell/sourcemap-codec" "^1.4.10" "@metadaoproject/futarchy@./sdk": - version "0.7.1-alpha.0" + version "0.7.1-alpha.1" dependencies: "@coral-xyz/anchor" "^0.29.0" "@metaplex-foundation/umi" "^0.9.2" From 233541d0a921e97aeb30e6dec96e14340daae231 Mon Sep 17 00:00:00 2001 From: Pileks Date: Mon, 9 Mar 2026 23:13:00 +0100 Subject: [PATCH 25/35] add pda_bump to initialization events --- programs/liquidation/src/events.rs | 2 ++ .../instructions/initialize_liquidation.rs | 1 + .../src/instructions/set_refund_record.rs | 1 + sdk/src/v0.7/types/liquidation.ts | 20 +++++++++++++++++++ 4 files changed, 24 insertions(+) diff --git a/programs/liquidation/src/events.rs b/programs/liquidation/src/events.rs index d44a6fc5e..3a05b9613 100644 --- a/programs/liquidation/src/events.rs +++ b/programs/liquidation/src/events.rs @@ -26,6 +26,7 @@ pub struct LiquidationCreatedEvent { pub base_mint: Pubkey, pub quote_mint: Pubkey, pub duration_seconds: u32, + pub pda_bump: u8, } #[event] @@ -44,6 +45,7 @@ pub struct RefundRecordSetEvent { pub recipient: Pubkey, pub base_assigned: u64, pub quote_refundable: u64, + pub pda_bump: u8, } #[event] diff --git a/programs/liquidation/src/instructions/initialize_liquidation.rs b/programs/liquidation/src/instructions/initialize_liquidation.rs index 53991cf87..29e3db12b 100644 --- a/programs/liquidation/src/instructions/initialize_liquidation.rs +++ b/programs/liquidation/src/instructions/initialize_liquidation.rs @@ -88,6 +88,7 @@ impl InitializeLiquidation<'_> { 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/set_refund_record.rs b/programs/liquidation/src/instructions/set_refund_record.rs index bec03056b..f7196adb0 100644 --- a/programs/liquidation/src/instructions/set_refund_record.rs +++ b/programs/liquidation/src/instructions/set_refund_record.rs @@ -107,6 +107,7 @@ impl SetRefundRecord<'_> { recipient: ctx.accounts.recipient.key(), base_assigned: args.base_assigned, quote_refundable: args.quote_refundable, + pda_bump: ctx.bumps.refund_record, }); Ok(()) diff --git a/sdk/src/v0.7/types/liquidation.ts b/sdk/src/v0.7/types/liquidation.ts index 91002329d..f662044e6 100644 --- a/sdk/src/v0.7/types/liquidation.ts +++ b/sdk/src/v0.7/types/liquidation.ts @@ -521,6 +521,11 @@ export type Liquidation = { type: "u32"; index: false; }, + { + name: "pdaBump"; + type: "u8"; + index: false; + }, ]; }, { @@ -585,6 +590,11 @@ export type Liquidation = { type: "u64"; index: false; }, + { + name: "pdaBump"; + type: "u8"; + index: false; + }, ]; }, { @@ -1254,6 +1264,11 @@ export const IDL: Liquidation = { type: "u32", index: false, }, + { + name: "pdaBump", + type: "u8", + index: false, + }, ], }, { @@ -1318,6 +1333,11 @@ export const IDL: Liquidation = { type: "u64", index: false, }, + { + name: "pdaBump", + type: "u8", + index: false, + }, ], }, { From 6505062b7fd850cfde01d6f8189b73c6da5830c7 Mon Sep 17 00:00:00 2001 From: Pileks Date: Mon, 9 Mar 2026 23:17:16 +0100 Subject: [PATCH 26/35] bump sdk ver --- sdk/package.json | 2 +- yarn.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/package.json b/sdk/package.json index c0fe6d5fd..942c00340 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@metadaoproject/futarchy", - "version": "0.7.1-alpha.1", + "version": "0.7.1-alpha.2", "type": "module", "main": "dist/index.js", "module": "dist/index.js", diff --git a/yarn.lock b/yarn.lock index 690465431..241119b84 100644 --- a/yarn.lock +++ b/yarn.lock @@ -975,7 +975,7 @@ "@jridgewell/sourcemap-codec" "^1.4.10" "@metadaoproject/futarchy@./sdk": - version "0.7.1-alpha.1" + version "0.7.1-alpha.2" dependencies: "@coral-xyz/anchor" "^0.29.0" "@metaplex-foundation/umi" "^0.9.2" From 79f6596f8b54317c9cf844ac04a747f3043de381 Mon Sep 17 00:00:00 2001 From: Pileks Date: Mon, 9 Mar 2026 23:35:52 +0100 Subject: [PATCH 27/35] add launch totals to refund record set event --- programs/liquidation/src/events.rs | 2 ++ .../src/instructions/set_refund_record.rs | 2 ++ sdk/src/v0.7/types/liquidation.ts | 20 +++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/programs/liquidation/src/events.rs b/programs/liquidation/src/events.rs index 3a05b9613..7a0c4881e 100644 --- a/programs/liquidation/src/events.rs +++ b/programs/liquidation/src/events.rs @@ -45,6 +45,8 @@ pub struct RefundRecordSetEvent { 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, } diff --git a/programs/liquidation/src/instructions/set_refund_record.rs b/programs/liquidation/src/instructions/set_refund_record.rs index f7196adb0..143291fdd 100644 --- a/programs/liquidation/src/instructions/set_refund_record.rs +++ b/programs/liquidation/src/instructions/set_refund_record.rs @@ -107,6 +107,8 @@ impl SetRefundRecord<'_> { 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, }); diff --git a/sdk/src/v0.7/types/liquidation.ts b/sdk/src/v0.7/types/liquidation.ts index f662044e6..517bbc362 100644 --- a/sdk/src/v0.7/types/liquidation.ts +++ b/sdk/src/v0.7/types/liquidation.ts @@ -590,6 +590,16 @@ export type Liquidation = { type: "u64"; index: false; }, + { + name: "liquidationTotalBaseAssigned"; + type: "u64"; + index: false; + }, + { + name: "liquidationTotalQuoteRefundable"; + type: "u64"; + index: false; + }, { name: "pdaBump"; type: "u8"; @@ -1333,6 +1343,16 @@ export const IDL: Liquidation = { type: "u64", index: false, }, + { + name: "liquidationTotalBaseAssigned", + type: "u64", + index: false, + }, + { + name: "liquidationTotalQuoteRefundable", + type: "u64", + index: false, + }, { name: "pdaBump", type: "u8", From 221ed7d3f076d323501dbf449ab395ba715b5de6 Mon Sep 17 00:00:00 2001 From: Pileks Date: Mon, 9 Mar 2026 23:41:28 +0100 Subject: [PATCH 28/35] bump sdk --- sdk/package.json | 2 +- yarn.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/package.json b/sdk/package.json index 942c00340..cbf8c3975 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@metadaoproject/futarchy", - "version": "0.7.1-alpha.2", + "version": "0.7.1-alpha.3", "type": "module", "main": "dist/index.js", "module": "dist/index.js", diff --git a/yarn.lock b/yarn.lock index 241119b84..90ca923f8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -975,7 +975,7 @@ "@jridgewell/sourcemap-codec" "^1.4.10" "@metadaoproject/futarchy@./sdk": - version "0.7.1-alpha.2" + version "0.7.1-alpha.3" dependencies: "@coral-xyz/anchor" "^0.29.0" "@metaplex-foundation/umi" "^0.9.2" From d985beffb41dad4e6c286500cd84752499ce066a Mon Sep 17 00:00:00 2001 From: Pileks Date: Tue, 10 Mar 2026 00:12:22 +0100 Subject: [PATCH 29/35] add create_key to liquidation initialization event --- programs/liquidation/src/events.rs | 1 + .../src/instructions/initialize_liquidation.rs | 1 + sdk/src/v0.7/types/liquidation.ts | 10 ++++++++++ 3 files changed, 12 insertions(+) diff --git a/programs/liquidation/src/events.rs b/programs/liquidation/src/events.rs index 7a0c4881e..759b89945 100644 --- a/programs/liquidation/src/events.rs +++ b/programs/liquidation/src/events.rs @@ -21,6 +21,7 @@ impl CommonFields { 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, diff --git a/programs/liquidation/src/instructions/initialize_liquidation.rs b/programs/liquidation/src/instructions/initialize_liquidation.rs index 29e3db12b..0258d186c 100644 --- a/programs/liquidation/src/instructions/initialize_liquidation.rs +++ b/programs/liquidation/src/instructions/initialize_liquidation.rs @@ -83,6 +83,7 @@ impl InitializeLiquidation<'_> { 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(), diff --git a/sdk/src/v0.7/types/liquidation.ts b/sdk/src/v0.7/types/liquidation.ts index 517bbc362..a83a162cf 100644 --- a/sdk/src/v0.7/types/liquidation.ts +++ b/sdk/src/v0.7/types/liquidation.ts @@ -496,6 +496,11 @@ export type Liquidation = { type: "publicKey"; index: false; }, + { + name: "createKey"; + type: "publicKey"; + index: false; + }, { name: "recordAuthority"; type: "publicKey"; @@ -1249,6 +1254,11 @@ export const IDL: Liquidation = { type: "publicKey", index: false, }, + { + name: "createKey", + type: "publicKey", + index: false, + }, { name: "recordAuthority", type: "publicKey", From 919b9a40a2db69062fd016ce8b7316db419f8c82 Mon Sep 17 00:00:00 2001 From: Pileks Date: Tue, 10 Mar 2026 00:13:27 +0100 Subject: [PATCH 30/35] v0.7.1-alpha.4 --- sdk/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/package.json b/sdk/package.json index cbf8c3975..6ba9675ef 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@metadaoproject/futarchy", - "version": "0.7.1-alpha.3", + "version": "0.7.1-alpha.4", "type": "module", "main": "dist/index.js", "module": "dist/index.js", From 5ffdf434d81169cc377fef73f898af4997ccee96 Mon Sep 17 00:00:00 2001 From: Pileks Date: Tue, 10 Mar 2026 19:54:08 +0100 Subject: [PATCH 31/35] add liquidation program to verifiable builds and deploy programs workflows --- .github/workflows/deploy-programs.yaml | 20 +++++++++++++++++++ .../workflows/generate-verifiable-builds.yaml | 19 +++++++++++++++++- CLAUDE.md | 1 + README.md | 1 + programs/liquidation/Cargo.toml | 1 + 5 files changed, 41 insertions(+), 1 deletion(-) 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/CLAUDE.md b/CLAUDE.md index 2fb10ae81..9140490e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -292,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/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 index 20671c8fe..b567ab434 100644 --- a/programs/liquidation/Cargo.toml +++ b/programs/liquidation/Cargo.toml @@ -14,6 +14,7 @@ no-idl = [] no-log-ix-name = [] cpi = ["no-entrypoint"] default = [] +production = [] [dependencies] anchor-lang = { version = "0.29.0", features = ["event-cpi", "init-if-needed"] } From 2eb131f18d0bece4c20488d591ff9d97a8449808 Mon Sep 17 00:00:00 2001 From: Pileks Date: Tue, 10 Mar 2026 20:12:00 +0100 Subject: [PATCH 32/35] prevent same base and quote mint on liquidation init, prevent griefing with ATA creation on liquidation init --- .../instructions/initialize_liquidation.rs | 7 +++++- .../unit/initializeLiquidation.test.ts | 23 +++++++++++++++++++ yarn.lock | 2 +- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/programs/liquidation/src/instructions/initialize_liquidation.rs b/programs/liquidation/src/instructions/initialize_liquidation.rs index 0258d186c..99015f1ea 100644 --- a/programs/liquidation/src/instructions/initialize_liquidation.rs +++ b/programs/liquidation/src/instructions/initialize_liquidation.rs @@ -41,7 +41,7 @@ pub struct InitializeLiquidation<'info> { pub liquidation: Account<'info, Liquidation>, #[account( - init, + init_if_needed, payer = payer, associated_token::mint = quote_mint, associated_token::authority = liquidation, @@ -55,6 +55,11 @@ pub struct InitializeLiquidation<'info> { impl InitializeLiquidation<'_> { pub fn validate(&self, args: &InitializeLiquidationArgs) -> Result<()> { + 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(()) diff --git a/tests/liquidation/unit/initializeLiquidation.test.ts b/tests/liquidation/unit/initializeLiquidation.test.ts index c4b844bf5..5872f809b 100644 --- a/tests/liquidation/unit/initializeLiquidation.test.ts +++ b/tests/liquidation/unit/initializeLiquidation.test.ts @@ -64,6 +64,29 @@ export default function suite() { 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, diff --git a/yarn.lock b/yarn.lock index 90ca923f8..62d62f9a3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -975,7 +975,7 @@ "@jridgewell/sourcemap-codec" "^1.4.10" "@metadaoproject/futarchy@./sdk": - version "0.7.1-alpha.3" + version "0.7.1-alpha.4" dependencies: "@coral-xyz/anchor" "^0.29.0" "@metaplex-foundation/umi" "^0.9.2" From 5e9fa39ff5965c72c10b0e5567af0fb832f2e723 Mon Sep 17 00:00:00 2001 From: Pileks Date: Tue, 10 Mar 2026 20:41:10 +0100 Subject: [PATCH 33/35] add proper seeds check --- programs/liquidation/src/instructions/refund.rs | 2 ++ .../liquidation/src/instructions/withdraw_remaining_quote.rs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/programs/liquidation/src/instructions/refund.rs b/programs/liquidation/src/instructions/refund.rs index 037bd8660..4763edc45 100644 --- a/programs/liquidation/src/instructions/refund.rs +++ b/programs/liquidation/src/instructions/refund.rs @@ -20,6 +20,8 @@ pub struct Refund<'info> { 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>, diff --git a/programs/liquidation/src/instructions/withdraw_remaining_quote.rs b/programs/liquidation/src/instructions/withdraw_remaining_quote.rs index d1f6bc7f6..5d93e2ac9 100644 --- a/programs/liquidation/src/instructions/withdraw_remaining_quote.rs +++ b/programs/liquidation/src/instructions/withdraw_remaining_quote.rs @@ -16,6 +16,8 @@ pub struct WithdrawRemainingQuote<'info> { 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>, From 42b41ab8289d8654d8e146a4e617d24c61339e26 Mon Sep 17 00:00:00 2001 From: Pileks Date: Tue, 10 Mar 2026 20:56:27 +0100 Subject: [PATCH 34/35] liquidation - use idiomatic assertions --- .../liquidation/src/instructions/initialize_liquidation.rs | 2 ++ programs/liquidation/src/instructions/refund.rs | 6 +++--- .../src/instructions/withdraw_remaining_quote.rs | 6 +++--- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/programs/liquidation/src/instructions/initialize_liquidation.rs b/programs/liquidation/src/instructions/initialize_liquidation.rs index 99015f1ea..63ac1c313 100644 --- a/programs/liquidation/src/instructions/initialize_liquidation.rs +++ b/programs/liquidation/src/instructions/initialize_liquidation.rs @@ -55,11 +55,13 @@ pub struct InitializeLiquidation<'info> { 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(()) diff --git a/programs/liquidation/src/instructions/refund.rs b/programs/liquidation/src/instructions/refund.rs index 4763edc45..014291079 100644 --- a/programs/liquidation/src/instructions/refund.rs +++ b/programs/liquidation/src/instructions/refund.rs @@ -72,9 +72,9 @@ impl Refund<'_> { LiquidationError::RefundingNotEnabled ); - require!( - clock.unix_timestamp - <= self.liquidation.started_at + self.liquidation.duration_seconds as i64, + require_gte!( + self.liquidation.started_at + self.liquidation.duration_seconds as i64, + clock.unix_timestamp, LiquidationError::RefundWindowExpired ); diff --git a/programs/liquidation/src/instructions/withdraw_remaining_quote.rs b/programs/liquidation/src/instructions/withdraw_remaining_quote.rs index 5d93e2ac9..d59a43462 100644 --- a/programs/liquidation/src/instructions/withdraw_remaining_quote.rs +++ b/programs/liquidation/src/instructions/withdraw_remaining_quote.rs @@ -49,9 +49,9 @@ impl WithdrawRemainingQuote<'_> { LiquidationError::RefundingNotEnabled ); - require!( - clock.unix_timestamp - > self.liquidation.started_at + self.liquidation.duration_seconds as i64, + require_gt!( + clock.unix_timestamp, + self.liquidation.started_at + self.liquidation.duration_seconds as i64, LiquidationError::RefundWindowNotExpired ); From b8ffed857f543343ae0b5bb1bd30e7ffe2183b28 Mon Sep 17 00:00:00 2001 From: Pileks Date: Tue, 10 Mar 2026 21:35:15 +0100 Subject: [PATCH 35/35] bump sdk ver --- sdk/package.json | 2 +- yarn.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/package.json b/sdk/package.json index 6ba9675ef..a0706d551 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@metadaoproject/futarchy", - "version": "0.7.1-alpha.4", + "version": "0.7.1-alpha.5", "type": "module", "main": "dist/index.js", "module": "dist/index.js", diff --git a/yarn.lock b/yarn.lock index 62d62f9a3..3d6230b68 100644 --- a/yarn.lock +++ b/yarn.lock @@ -975,7 +975,7 @@ "@jridgewell/sourcemap-codec" "^1.4.10" "@metadaoproject/futarchy@./sdk": - version "0.7.1-alpha.4" + version "0.7.1-alpha.5" dependencies: "@coral-xyz/anchor" "^0.29.0" "@metaplex-foundation/umi" "^0.9.2"