diff --git a/Makefile b/Makefile index 13af695..1faffdc 100644 --- a/Makefile +++ b/Makefile @@ -4,15 +4,15 @@ build-programs: @mv target/deploy/m_ext.so target/deploy/scaled_ui.so @mv target/idl/m_ext.json target/idl/scaled_ui.json @mv target/types/m_ext.ts target/types/scaled_ui.ts - anchor build -p m_ext -- --features no-yield --no-default-features - @mv target/deploy/m_ext.so target/deploy/no_yield.so - @mv target/idl/m_ext.json target/idl/no_yield.json - @mv target/types/m_ext.ts target/types/no_yield.ts + anchor build -p m_ext -- --features jmi,no-yield --no-default-features + @mv target/deploy/m_ext.so target/deploy/jmi.so + @mv target/idl/m_ext.json target/idl/jmi.json + @mv target/types/m_ext.ts target/types/jmi.ts anchor build -p m_ext -- --features crank --no-default-features @mv target/deploy/m_ext.so target/deploy/crank.so @mv target/idl/m_ext.json target/idl/crank.json @mv target/types/m_ext.ts target/types/crank.ts - anchor build -p m_ext -- --features no-yield,migrate --no-default-features + anchor build -p m_ext -- --features no-yield,jmi,migrate --no-default-features @mv target/idl/m_ext.json target/idl/migrate.json @mv target/types/m_ext.ts target/types/migrate.ts @@ -27,7 +27,7 @@ endef build-test-programs: $(call update-program-id,3joDhmLtHLrSBGfeAe1xQiv3gjikes3x8S4N3o6Ld8zB) - anchor build -p m_ext + anchor build -p m_ext -- --features jmi,no-yield --no-default-features @mv target/deploy/m_ext.so tests/programs/ext_a.so $(call update-program-id,HSMnbWEkB7sEQAGSzBPeACNUCXC9FgNeeESLnHtKfoy3) anchor build -p m_ext -- --features scaled-ui --no-default-features diff --git a/programs/ext_swap/Cargo.toml b/programs/ext_swap/Cargo.toml index 65f42c7..a325063 100644 --- a/programs/ext_swap/Cargo.toml +++ b/programs/ext_swap/Cargo.toml @@ -23,4 +23,4 @@ anchor-lang.workspace = true anchor-spl.workspace = true earn.workspace = true solana-security-txt.workspace = true -m_ext = {path = "../m_ext", features = ["cpi"]} +m_ext = {path = "../m_ext", features = ["cpi", "jmi"]} diff --git a/programs/ext_swap/src/instructions/mod.rs b/programs/ext_swap/src/instructions/mod.rs index 62a8a67..312cf6e 100644 --- a/programs/ext_swap/src/instructions/mod.rs +++ b/programs/ext_swap/src/instructions/mod.rs @@ -1,11 +1,15 @@ pub mod initialize; +pub mod replace_asset_with_m; pub mod swap; pub mod unwrap; pub mod whitelist; pub mod wrap; +pub mod wrap_asset; pub use initialize::*; +pub use replace_asset_with_m::*; pub use swap::*; pub use unwrap::*; pub use whitelist::*; pub use wrap::*; +pub use wrap_asset::*; diff --git a/programs/ext_swap/src/instructions/replace_asset_with_m.rs b/programs/ext_swap/src/instructions/replace_asset_with_m.rs new file mode 100644 index 0000000..9556821 --- /dev/null +++ b/programs/ext_swap/src/instructions/replace_asset_with_m.rs @@ -0,0 +1,252 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; +use m_ext::cpi::accounts::{ReplaceAssetWithM as ExtReplaceAssetWithM, Unwrap}; +use m_ext::state::{EXT_GLOBAL_SEED, MINT_AUTHORITY_SEED, M_VAULT_SEED}; + +use crate::{ + errors::SwapError, + state::{SwapGlobal, GLOBAL_SEED, REPLACE_AUTHORITY_SEED}, +}; + +#[derive(Accounts)] +pub struct ReplaceAssetWithM<'info> { + pub signer: Signer<'info>, + + // Required if the fallback_replace_authority is not whitelisted on the extension + pub replace_authority: Option>, + + /// CHECK: PDA used as replace authority for JMI extensions + #[account( + seeds = [REPLACE_AUTHORITY_SEED], + bump, + )] + pub fallback_replace_authority: AccountInfo<'info>, + + /* + * Program globals + */ + #[account( + seeds = [GLOBAL_SEED], + bump = swap_global.bump, + )] + pub swap_global: Box>, + + /// Source extension global (for unwrap) + #[account( + mut, + seeds = [EXT_GLOBAL_SEED], + seeds::program = from_ext_program.key(), + bump, + )] + /// CHECK: CPI will validate the global account + pub from_global: AccountInfo<'info>, + + /// JMI extension global (for replace_asset_with_m) + #[account( + mut, + seeds = [EXT_GLOBAL_SEED], + seeds::program = jmi_ext_program.key(), + bump, + )] + /// CHECK: CPI will validate the global account + pub jmi_global: AccountInfo<'info>, + + /* + * Mints + */ + #[account(mut)] + /// Validated by unwrap on the extension program + pub from_mint: Box>, + #[account(mint::token_program = m_token_program)] + pub m_mint: Box>, + #[account(mint::token_program = asset_token_program)] + pub asset_mint: Box>, + + /* + * Asset config for JMI + */ + #[account(mut)] + /// CHECK: CPI will validate the asset config + pub asset_config: AccountInfo<'info>, + + /* + * Token Accounts + */ + #[account( + mut, + token::mint = from_mint, + token::token_program = from_token_program, + )] + pub from_token_account: Box>, + #[account( + mut, + token::mint = asset_mint, + token::token_program = asset_token_program, + )] + pub to_asset_token_account: Box>, + #[account( + mut, + associated_token::mint = m_mint, + associated_token::authority = swap_global, + associated_token::token_program = m_token_program, + )] + pub swap_m_account: Box>, + + /* + * Authorities & Vaults for source extension (unwrap) + */ + #[account( + seeds = [M_VAULT_SEED], + seeds::program = from_ext_program.key(), + bump, + )] + /// CHECK: account does not hold data + pub from_m_vault_auth: AccountInfo<'info>, + #[account( + seeds = [MINT_AUTHORITY_SEED], + seeds::program = from_ext_program.key(), + bump, + )] + /// CHECK: account does not hold data + pub from_mint_authority: AccountInfo<'info>, + #[account( + mut, + associated_token::mint = m_mint, + associated_token::authority = from_m_vault_auth, + associated_token::token_program = m_token_program, + )] + pub from_m_vault: Box>, + + /* + * Vaults for JMI extension (replace_asset_with_m) + */ + #[account( + seeds = [M_VAULT_SEED], + seeds::program = jmi_ext_program.key(), + bump, + )] + /// CHECK: account does not hold data + pub jmi_m_vault_auth: AccountInfo<'info>, + #[account( + mut, + associated_token::mint = m_mint, + associated_token::authority = jmi_m_vault_auth, + associated_token::token_program = m_token_program, + )] + pub jmi_m_vault: Box>, + #[account( + mut, + associated_token::mint = asset_mint, + associated_token::authority = jmi_m_vault_auth, + associated_token::token_program = asset_token_program, + )] + pub jmi_asset_vault: Box>, + + /* + * Token Programs + */ + pub from_token_program: Interface<'info, TokenInterface>, + pub asset_token_program: Interface<'info, TokenInterface>, + pub m_token_program: Interface<'info, TokenInterface>, + + /* + * Programs + */ + /// CHECK: checked against whitelisted extensions + pub from_ext_program: UncheckedAccount<'info>, + /// CHECK: checked against whitelisted extensions + pub jmi_ext_program: UncheckedAccount<'info>, + pub system_program: Program<'info, System>, +} + +impl<'info> ReplaceAssetWithM<'info> { + fn validate(&self, ext_principal: u64) -> Result<()> { + // Validate both extensions are whitelisted + for ext_program in [&self.from_ext_program, &self.jmi_ext_program] { + if !self.swap_global.is_extension_whitelisted(ext_program.key) { + return err!(SwapError::InvalidExtension); + } + } + + if ext_principal == 0 { + return err!(SwapError::InvalidAmount); + } + + Ok(()) + } + + #[access_control(ctx.accounts.validate(ext_principal))] + pub fn handler(ctx: Context<'_, '_, '_, 'info, Self>, ext_principal: u64) -> Result<()> { + let m_pre_balance = ctx.accounts.swap_m_account.amount; + + // Set replace authority as authority if none provided + let replace_authority = match &ctx.accounts.replace_authority { + Some(auth) => auth.to_account_info(), + None => ctx.accounts.fallback_replace_authority.to_account_info(), + }; + + // 1. Unwrap source extension → M (to swap_m_account) + m_ext::cpi::unwrap( + CpiContext::new_with_signer( + ctx.accounts.from_ext_program.to_account_info(), + Unwrap { + token_authority: ctx.accounts.signer.to_account_info(), + unwrap_authority: Some(replace_authority.clone()), + m_mint: ctx.accounts.m_mint.to_account_info(), + ext_mint: ctx.accounts.from_mint.to_account_info(), + global_account: ctx.accounts.from_global.to_account_info(), + m_vault: ctx.accounts.from_m_vault_auth.to_account_info(), + ext_mint_authority: ctx.accounts.from_mint_authority.to_account_info(), + to_m_token_account: ctx.accounts.swap_m_account.to_account_info(), + vault_m_token_account: ctx.accounts.from_m_vault.to_account_info(), + from_ext_token_account: ctx.accounts.from_token_account.to_account_info(), + m_token_program: ctx.accounts.m_token_program.to_account_info(), + ext_token_program: ctx.accounts.from_token_program.to_account_info(), + }, + &[&[ + REPLACE_AUTHORITY_SEED, + &[ctx.bumps.fallback_replace_authority], + ]], + ), + ext_principal, + )?; + + // 2. Calculate M received + ctx.accounts.swap_m_account.reload()?; + let m_principal = ctx.accounts.swap_m_account.amount - m_pre_balance; + + // 3. Call JMI replace_asset_with_m (swap_global signs for token transfer, replace_authority for authorization) + m_ext::cpi::replace_asset_with_m( + CpiContext::new_with_signer( + ctx.accounts.jmi_ext_program.to_account_info(), + ExtReplaceAssetWithM { + token_authority: ctx.accounts.swap_global.to_account_info(), + replace_authority: Some(replace_authority), + m_mint: ctx.accounts.m_mint.to_account_info(), + asset_mint: ctx.accounts.asset_mint.to_account_info(), + global_account: ctx.accounts.jmi_global.to_account_info(), + asset_config: ctx.accounts.asset_config.to_account_info(), + m_vault: ctx.accounts.jmi_m_vault_auth.to_account_info(), + from_m_token_account: ctx.accounts.swap_m_account.to_account_info(), + vault_m_token_account: ctx.accounts.jmi_m_vault.to_account_info(), + vault_asset_token_account: ctx.accounts.jmi_asset_vault.to_account_info(), + to_asset_token_account: ctx.accounts.to_asset_token_account.to_account_info(), + m_token_program: ctx.accounts.m_token_program.to_account_info(), + asset_token_program: ctx.accounts.asset_token_program.to_account_info(), + }, + &[ + &[ + REPLACE_AUTHORITY_SEED, + &[ctx.bumps.fallback_replace_authority], + ], + &[GLOBAL_SEED, &[ctx.accounts.swap_global.bump]], + ], + ), + m_principal, + )?; + + msg!("{} ext_principal -> {} m_principal -> asset", ext_principal, m_principal); + + Ok(()) + } +} diff --git a/programs/ext_swap/src/instructions/wrap_asset.rs b/programs/ext_swap/src/instructions/wrap_asset.rs new file mode 100644 index 0000000..f06865f --- /dev/null +++ b/programs/ext_swap/src/instructions/wrap_asset.rs @@ -0,0 +1,165 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; +use m_ext::cpi::accounts::WrapAsset as ExtWrapAsset; +use m_ext::state::{EXT_GLOBAL_SEED, MINT_AUTHORITY_SEED, M_VAULT_SEED}; + +use crate::errors::SwapError; +use crate::state::{SwapGlobal, GLOBAL_SEED, REPLACE_AUTHORITY_SEED}; + +#[derive(Accounts)] +pub struct WrapAsset<'info> { + pub signer: Signer<'info>, + + // Required if the fallback_replace_authority is not whitelisted on the extension + pub replace_authority: Option>, + + /// CHECK: PDA used as replace authority for extensions + #[account( + seeds = [REPLACE_AUTHORITY_SEED], + bump, + )] + pub fallback_replace_authority: AccountInfo<'info>, + + /* + * Program globals + */ + #[account( + seeds = [GLOBAL_SEED], + bump = swap_global.bump, + )] + pub swap_global: Box>, + #[account( + mut, + seeds = [EXT_GLOBAL_SEED], + seeds::program = to_ext_program.key(), + bump, + )] + /// CHECK: CPI will validate the global account + pub to_global: AccountInfo<'info>, + + /* + * Mints + */ + #[account(mut)] + /// Validated by wrap_asset on the extension program + pub to_mint: Box>, + #[account(mint::token_program = asset_token_program)] + pub asset_mint: Box>, + + /* + * Asset config - required for wrap_asset + */ + #[account(mut)] + /// CHECK: CPI will validate the asset config + pub asset_config: AccountInfo<'info>, + + /* + * Token Accounts + */ + #[account( + mut, + token::mint = asset_mint, + token::token_program = asset_token_program, + )] + pub asset_token_account: Box>, + #[account( + mut, + token::mint = to_mint, + token::token_program = to_token_program, + )] + pub to_token_account: Box>, + + /* + * Authorities + */ + #[account( + seeds = [M_VAULT_SEED], + seeds::program = to_ext_program.key(), + bump, + )] + /// CHECK: account does not hold data + pub to_vault_auth: AccountInfo<'info>, + #[account( + seeds = [MINT_AUTHORITY_SEED], + seeds::program = to_ext_program.key(), + bump, + )] + /// CHECK: account does not hold data + pub to_mint_authority: AccountInfo<'info>, + + /* + * Vaults + */ + #[account( + mut, + associated_token::mint = asset_mint, + associated_token::authority = to_vault_auth, + associated_token::token_program = asset_token_program, + )] + pub to_asset_vault: Box>, + + /* + * Token Programs + */ + pub to_token_program: Interface<'info, TokenInterface>, + pub asset_token_program: Interface<'info, TokenInterface>, + + /* + * Programs + */ + /// CHECK: checked against whitelisted extensions + pub to_ext_program: UncheckedAccount<'info>, + pub system_program: Program<'info, System>, +} + +impl<'info> WrapAsset<'info> { + fn validate(&self, amount: u64) -> Result<()> { + if !self + .swap_global + .is_extension_whitelisted(self.to_ext_program.key) + { + return err!(SwapError::InvalidExtension); + } + + if amount == 0 { + return err!(SwapError::InvalidAmount); + } + + Ok(()) + } + + #[access_control(ctx.accounts.validate(amount))] + pub fn handler(ctx: Context<'_, '_, '_, 'info, Self>, amount: u64) -> Result<()> { + // Set replace authority PDA as authority if none provided + let replace_authority = match &ctx.accounts.replace_authority { + Some(auth) => auth.to_account_info(), + None => ctx.accounts.fallback_replace_authority.to_account_info(), + }; + + m_ext::cpi::wrap_asset( + CpiContext::new_with_signer( + ctx.accounts.to_ext_program.to_account_info(), + ExtWrapAsset { + token_authority: ctx.accounts.signer.to_account_info(), + replace_authority: Some(replace_authority), + asset_mint: ctx.accounts.asset_mint.to_account_info(), + ext_mint: ctx.accounts.to_mint.to_account_info(), + global_account: ctx.accounts.to_global.to_account_info(), + asset_config: ctx.accounts.asset_config.to_account_info(), + asset_vault: ctx.accounts.to_vault_auth.to_account_info(), + ext_mint_authority: ctx.accounts.to_mint_authority.to_account_info(), + from_asset_token_account: ctx.accounts.asset_token_account.to_account_info(), + vault_asset_token_account: ctx.accounts.to_asset_vault.to_account_info(), + to_ext_token_account: ctx.accounts.to_token_account.to_account_info(), + asset_token_program: ctx.accounts.asset_token_program.to_account_info(), + ext_token_program: ctx.accounts.to_token_program.to_account_info(), + }, + &[&[ + REPLACE_AUTHORITY_SEED, + &[ctx.bumps.fallback_replace_authority], + ]], + ), + amount, + ) + } +} diff --git a/programs/ext_swap/src/lib.rs b/programs/ext_swap/src/lib.rs index 0db817d..5550adf 100644 --- a/programs/ext_swap/src/lib.rs +++ b/programs/ext_swap/src/lib.rs @@ -66,8 +66,11 @@ pub mod ext_swap { Swap::handler(ctx, amount, remaining_accounts_split_idx as usize) } - pub fn wrap<'info>(ctx: Context<'_, '_, '_, 'info, Wrap<'info>>, amount: u64) -> Result<()> { - Wrap::handler(ctx, amount) + pub fn wrap<'info>( + ctx: Context<'_, '_, '_, 'info, Wrap<'info>>, + m_principal: u64, + ) -> Result<()> { + Wrap::handler(ctx, m_principal) } pub fn unwrap<'info>( @@ -76,4 +79,18 @@ pub mod ext_swap { ) -> Result<()> { Unwrap::handler(ctx, amount) } + + pub fn replace_asset_with_m<'info>( + ctx: Context<'_, '_, '_, 'info, ReplaceAssetWithM<'info>>, + ext_principal: u64, + ) -> Result<()> { + ReplaceAssetWithM::handler(ctx, ext_principal) + } + + pub fn wrap_asset<'info>( + ctx: Context<'_, '_, '_, 'info, WrapAsset<'info>>, + amount: u64, + ) -> Result<()> { + WrapAsset::handler(ctx, amount) + } } diff --git a/programs/ext_swap/src/state.rs b/programs/ext_swap/src/state.rs index 0cd82f6..9e0bf73 100644 --- a/programs/ext_swap/src/state.rs +++ b/programs/ext_swap/src/state.rs @@ -3,6 +3,9 @@ use anchor_lang::prelude::*; #[constant] pub const GLOBAL_SEED: &[u8] = b"global"; +#[constant] +pub const REPLACE_AUTHORITY_SEED: &[u8] = b"replace_authority"; + #[account] pub struct SwapGlobal { pub bump: u8, diff --git a/programs/m_ext/Cargo.toml b/programs/m_ext/Cargo.toml index 1608d4e..85261e0 100644 --- a/programs/m_ext/Cargo.toml +++ b/programs/m_ext/Cargo.toml @@ -13,12 +13,13 @@ no-entrypoint = [] no-idl = [] no-log-ix-name = [] cpi = ["no-entrypoint"] -default = ["no-yield"] +default = ["jmi", "no-yield"] idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"] # yield features scaled-ui = [] no-yield = [] +jmi = [] crank = [] # Migration feature flag diff --git a/programs/m_ext/src/errors.rs b/programs/m_ext/src/errors.rs index 32b6c35..511ac59 100644 --- a/programs/m_ext/src/errors.rs +++ b/programs/m_ext/src/errors.rs @@ -34,4 +34,16 @@ pub enum ExtError { InvalidTokenProgram, #[msg("Vault token account frozen.")] VaultFrozen, + #[msg("Asset not allowed for wrapping.")] + AssetNotAllowed, + #[msg("Asset cap would be exceeded.")] + AssetCapExceeded, + #[msg("Cannot set cap for M token.")] + CannotCapMToken, + #[msg("Insufficient asset backing for replacement.")] + InsufficientAssetBacking, + #[msg("Asset must have 6 decimals.")] + InvalidDecimals, + #[msg("Asset has unsupported Token 2022 extension.")] + UnsupportedExtension, } diff --git a/programs/m_ext/src/instructions/claim_fees.rs b/programs/m_ext/src/instructions/claim_fees.rs index 5530c18..3353a35 100644 --- a/programs/m_ext/src/instructions/claim_fees.rs +++ b/programs/m_ext/src/instructions/claim_fees.rs @@ -93,10 +93,21 @@ impl ClaimFees<'_> { &ctx.accounts.ext_token_program, )?; - // Calculate the required collateral, rounding down to be conservative // This amount will always be greater than what is required in the check_solvency function // since it allows a rounding error of up to 2e-6 - let required_m = principal_to_amount_up(ctx.accounts.ext_mint.supply, ext_index)?; + // + // Convert ext supply from principal to amount units first + let ext_supply_amount = principal_to_amount_up(ctx.accounts.ext_mint.supply, ext_index)?; + + // For JMI: subtract total_assets (non-M backing) to get M-backed portion + // total_assets is already in amount units (6 decimals) + #[cfg(feature = "jmi")] + let required_m = ext_supply_amount + .checked_sub(ctx.accounts.global_account.yield_config.total_assets) + .ok_or(ExtError::MathUnderflow)?; + + #[cfg(not(feature = "jmi"))] + let required_m = ext_supply_amount; // Get the scaled UI config for the M mint to convert principal in the vault to M units let m_config = get_scaled_ui_config(&ctx.accounts.m_mint)?; diff --git a/programs/m_ext/src/instructions/initialize.rs b/programs/m_ext/src/instructions/initialize.rs index ec70160..24393a4 100644 --- a/programs/m_ext/src/instructions/initialize.rs +++ b/programs/m_ext/src/instructions/initialize.rs @@ -38,7 +38,7 @@ cfg_if! { } #[derive(Accounts)] -#[instruction(wrap_authorities: Vec)] +#[instruction(wrap_authorities: Vec, replace_authorities: Vec)] pub struct Initialize<'info> { #[account(mut)] pub admin: Signer<'info>, @@ -47,7 +47,8 @@ pub struct Initialize<'info> { init, payer = admin, space = ExtGlobalV2::size( - wrap_authorities.len() + wrap_authorities.len(), + replace_authorities.len() ), seeds = [EXT_GLOBAL_SEED], bump @@ -165,6 +166,7 @@ impl Initialize<'_> { pub fn handler( ctx: Context, wrap_authorities: Vec, + replace_authorities: Vec, fee_bps: Option, earn_authority: Option, ) -> Result<()> { @@ -174,6 +176,12 @@ impl Initialize<'_> { return err!(ExtError::InvalidParam); } + // Create hash set from replace_authorities to ensure uniqueness + let replace_auth_set: HashSet = replace_authorities.clone().into_iter().collect(); + if replace_auth_set.len() < replace_authorities.len() { + return err!(ExtError::InvalidParam); + } + // Create the yield config let yield_config: YieldConfig; cfg_if! { @@ -199,8 +207,10 @@ impl Initialize<'_> { timestamp: timestamp as u64, }; } else { + // JMI extends no-yield with additional fields yield_config = YieldConfig { yield_variant: YieldVariant::NoYield, + total_assets: 0, }; } } @@ -217,6 +227,7 @@ impl Initialize<'_> { ext_mint_authority_bump: ctx.bumps.ext_mint_authority, yield_config, wrap_authorities, + replace_authorities, }); // Set the ScaledUi multiplier to 1.0 diff --git a/programs/m_ext/src/instructions/jmi/manage_replace_authority.rs b/programs/m_ext/src/instructions/jmi/manage_replace_authority.rs new file mode 100644 index 0000000..7094f14 --- /dev/null +++ b/programs/m_ext/src/instructions/jmi/manage_replace_authority.rs @@ -0,0 +1,126 @@ +use anchor_lang::prelude::*; + +use crate::{ + errors::ExtError, + state::{ExtGlobalV2, EXT_GLOBAL_SEED}, +}; + +#[derive(Accounts)] +pub struct AddReplaceAuthority<'info> { + #[account(mut)] + pub admin: Signer<'info>, + + #[account( + mut, + seeds = [EXT_GLOBAL_SEED], + has_one = admin @ ExtError::NotAuthorized, + bump = global_account.bump, + realloc = ExtGlobalV2::size( + global_account.wrap_authorities.len(), + global_account.replace_authorities.len() + 1 + ), + realloc::payer = admin, + realloc::zero = false, + )] + pub global_account: Account<'info, ExtGlobalV2>, + + pub system_program: Program<'info, System>, +} + +impl AddReplaceAuthority<'_> { + // This instruction allows the admin to add a replace authority to the global account. + // The new replace authority must not already exist in the list. + + pub fn validate(&self, new_replace_authority: Pubkey) -> Result<()> { + // Validate that the new replace authority is not already in the list + if self + .global_account + .replace_authorities + .contains(&new_replace_authority) + { + return err!(ExtError::InvalidParam); + } + + Ok(()) + } + + #[access_control(ctx.accounts.validate(new_replace_authority))] + pub fn handler(ctx: Context, new_replace_authority: Pubkey) -> Result<()> { + // Add the new replace authority + ctx.accounts + .global_account + .replace_authorities + .push(new_replace_authority); + + Ok(()) + } +} + +#[derive(Accounts)] +pub struct RemoveReplaceAuthority<'info> { + #[account(mut)] + pub admin: Signer<'info>, + + #[account( + mut, + seeds = [EXT_GLOBAL_SEED], + has_one = admin @ ExtError::NotAuthorized, + bump = global_account.bump, + )] + pub global_account: Account<'info, ExtGlobalV2>, + + pub system_program: Program<'info, System>, +} + +impl RemoveReplaceAuthority<'_> { + // This instruction allows the admin to remove a replace authority from the global account. + // The replace authority must exist in the list. + + pub fn validate(&self, replace_authority: Pubkey) -> Result<()> { + // Validate that the replace authority exists in the list + if !self + .global_account + .replace_authorities + .contains(&replace_authority) + { + return err!(ExtError::InvalidParam); + } + + Ok(()) + } + + #[access_control(ctx.accounts.validate(replace_authority))] + pub fn handler(ctx: Context, replace_authority: Pubkey) -> Result<()> { + // Remove the specified replace authority + ctx.accounts + .global_account + .replace_authorities + .retain(|&x| !x.eq(&replace_authority)); + + // Reallocate the account to remove the empty space without erasing the other data + let new_size = ExtGlobalV2::size( + ctx.accounts.global_account.wrap_authorities.len(), + ctx.accounts.global_account.replace_authorities.len(), + ); + ctx.accounts + .global_account + .to_account_info() + .realloc(new_size, false)?; + + // Refund excess lamports to the admin + let current_lamports = ctx.accounts.global_account.to_account_info().lamports(); + let required_lamports = Rent::get()?.minimum_balance(new_size); + let excess_lamports = current_lamports.saturating_sub(required_lamports); + if excess_lamports > 0 { + **ctx + .accounts + .global_account + .to_account_info() + .lamports + .borrow_mut() -= excess_lamports; + **ctx.accounts.admin.to_account_info().lamports.borrow_mut() += excess_lamports; + } + + Ok(()) + } +} diff --git a/programs/m_ext/src/instructions/jmi/mod.rs b/programs/m_ext/src/instructions/jmi/mod.rs new file mode 100644 index 0000000..158c3bf --- /dev/null +++ b/programs/m_ext/src/instructions/jmi/mod.rs @@ -0,0 +1,9 @@ +pub mod manage_replace_authority; +pub mod replace_asset_with_m; +pub mod set_asset_cap; +pub mod wrap_asset; + +pub use manage_replace_authority::*; +pub use replace_asset_with_m::*; +pub use set_asset_cap::*; +pub use wrap_asset::*; diff --git a/programs/m_ext/src/instructions/jmi/replace_asset_with_m.rs b/programs/m_ext/src/instructions/jmi/replace_asset_with_m.rs new file mode 100644 index 0000000..5d10ac8 --- /dev/null +++ b/programs/m_ext/src/instructions/jmi/replace_asset_with_m.rs @@ -0,0 +1,148 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; + +use crate::{ + errors::ExtError, + state::{AssetConfig, ExtGlobalV2, ASSET_CONFIG_SEED, EXT_GLOBAL_SEED, M_VAULT_SEED}, + utils::{ + conversion::{multiplier_to_index, principal_to_amount_down}, + token::{transfer_tokens, transfer_tokens_from_program}, + }, +}; +use earn::utils::conversion::get_scaled_ui_config; + +#[derive(Accounts)] +pub struct ReplaceAssetWithM<'info> { + pub token_authority: Signer<'info>, + + /// Will be set if a whitelisted authority is signing for a user + pub replace_authority: Option>, + + #[account(mint::token_program = m_token_program)] + pub m_mint: InterfaceAccount<'info, Mint>, + + #[account( + mint::token_program = asset_token_program, + constraint = asset_mint.key() != m_mint.key() @ ExtError::AssetNotAllowed, + )] + pub asset_mint: InterfaceAccount<'info, Mint>, + + #[account( + mut, + seeds = [EXT_GLOBAL_SEED], + bump = global_account.bump, + has_one = m_mint @ ExtError::InvalidAccount, + )] + pub global_account: Account<'info, ExtGlobalV2>, + + #[account( + seeds = [ASSET_CONFIG_SEED, asset_mint.key().as_ref()], + bump = asset_config.bump, + )] + pub asset_config: Account<'info, AssetConfig>, + + /// CHECK: Validated by seed + #[account( + seeds = [M_VAULT_SEED], + bump = global_account.m_vault_bump, + )] + pub m_vault: AccountInfo<'info>, + + #[account( + mut, + token::mint = m_mint, + token::token_program = m_token_program, + )] + pub from_m_token_account: InterfaceAccount<'info, TokenAccount>, + + #[account( + mut, + associated_token::mint = m_mint, + associated_token::authority = m_vault, + associated_token::token_program = m_token_program, + )] + pub vault_m_token_account: InterfaceAccount<'info, TokenAccount>, + + #[account( + mut, + associated_token::mint = asset_mint, + associated_token::authority = m_vault, + associated_token::token_program = asset_token_program, + )] + pub vault_asset_token_account: InterfaceAccount<'info, TokenAccount>, + + #[account( + mut, + token::mint = asset_mint, + token::token_program = asset_token_program, + )] + pub to_asset_token_account: InterfaceAccount<'info, TokenAccount>, + + pub m_token_program: Interface<'info, TokenInterface>, + pub asset_token_program: Interface<'info, TokenInterface>, +} + +impl ReplaceAssetWithM<'_> { + pub fn validate(&self, m_principal: u64) -> Result<()> { + let auth = match &self.replace_authority { + Some(auth) => auth.key, + None => self.token_authority.key, + }; + + // Ensure the caller is authorized + if !self.global_account.replace_authorities.contains(auth) { + return err!(ExtError::NotAuthorized); + } + + if m_principal == 0 { + return err!(ExtError::InvalidAmount); + } + + Ok(()) + } + + #[access_control(ctx.accounts.validate(m_principal))] + pub fn handler(ctx: Context, m_principal: u64) -> Result<()> { + // Get M index and convert principal to economic value + let m_scaled_ui_config = get_scaled_ui_config(&ctx.accounts.m_mint)?; + let m_index: u64 = multiplier_to_index(m_scaled_ui_config.new_multiplier.into())?; + let asset_amount: u64 = principal_to_amount_down(m_principal, m_index)?; + + // Validate sufficient asset backing + if asset_amount > ctx.accounts.vault_asset_token_account.amount { + return err!(ExtError::InsufficientAssetBacking); + } + + // Transfer M from caller to vault + transfer_tokens( + &ctx.accounts.from_m_token_account, + &ctx.accounts.vault_m_token_account, + m_principal, + &ctx.accounts.m_mint, + &ctx.accounts.token_authority.to_account_info(), + &ctx.accounts.m_token_program, + )?; + + // Transfer asset from vault to recipient + transfer_tokens_from_program( + &ctx.accounts.vault_asset_token_account, + &ctx.accounts.to_asset_token_account, + asset_amount, + &ctx.accounts.asset_mint, + &ctx.accounts.m_vault, + &[&[M_VAULT_SEED, &[ctx.accounts.global_account.m_vault_bump]]], + &ctx.accounts.asset_token_program, + )?; + + // Update tracking + ctx.accounts.global_account.yield_config.total_assets = ctx + .accounts + .global_account + .yield_config + .total_assets + .checked_sub(asset_amount) + .ok_or(ExtError::MathUnderflow)?; + + Ok(()) + } +} diff --git a/programs/m_ext/src/instructions/jmi/set_asset_cap.rs b/programs/m_ext/src/instructions/jmi/set_asset_cap.rs new file mode 100644 index 0000000..c737b30 --- /dev/null +++ b/programs/m_ext/src/instructions/jmi/set_asset_cap.rs @@ -0,0 +1,97 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{Mint, TokenAccount, TokenInterface}, +}; +use spl_token_2022::extension::ExtensionType; + +use crate::{ + errors::ExtError, + state::{AssetConfig, ExtGlobalV2, ASSET_CONFIG_SEED, EXT_GLOBAL_SEED, M_VAULT_SEED}, + utils::conversion::get_mint_extensions, +}; + +#[derive(Accounts)] +pub struct SetAssetCap<'info> { + #[account(mut)] + pub admin: Signer<'info>, + + #[account(mint::token_program = asset_token_program)] + pub asset_mint: InterfaceAccount<'info, Mint>, + + #[account( + mut, + seeds = [EXT_GLOBAL_SEED], + bump = global_account.bump, + has_one = admin @ ExtError::NotAuthorized, + )] + pub global_account: Account<'info, ExtGlobalV2>, + + #[account( + init_if_needed, + payer = admin, + space = 8 + AssetConfig::INIT_SPACE, + seeds = [ASSET_CONFIG_SEED, asset_mint.key().as_ref()], + bump, + )] + pub asset_config: Account<'info, AssetConfig>, + + /// CHECK: PDA used as vault authority for all token types + #[account( + seeds = [M_VAULT_SEED], + bump = global_account.m_vault_bump, + )] + pub vault_authority: AccountInfo<'info>, + + /// Asset vault ATA - created if needed + #[account( + init_if_needed, + payer = admin, + associated_token::mint = asset_mint, + associated_token::authority = vault_authority, + associated_token::token_program = asset_token_program, + )] + pub vault_asset_token_account: InterfaceAccount<'info, TokenAccount>, + + pub asset_token_program: Interface<'info, TokenInterface>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} + +impl SetAssetCap<'_> { + pub fn validate(&self) -> Result<()> { + // Cannot set cap for M token + if self.asset_mint.key() == self.global_account.m_mint { + return err!(ExtError::CannotCapMToken); + } + // Only accept assets with 6 decimals + if self.asset_mint.decimals != 6 { + return err!(ExtError::InvalidDecimals); + } + // Reject assets with problematic Token 2022 extensions + let extensions = get_mint_extensions(&self.asset_mint)?; + if extensions.contains(&ExtensionType::ScaledUiAmount) { + return err!(ExtError::UnsupportedExtension); + } + if extensions.contains(&ExtensionType::TransferFeeConfig) { + return err!(ExtError::UnsupportedExtension); + } + if extensions.contains(&ExtensionType::NonTransferable) { + return err!(ExtError::UnsupportedExtension); + } + if extensions.contains(&ExtensionType::InterestBearingConfig) { + return err!(ExtError::UnsupportedExtension); + } + Ok(()) + } + + #[access_control(ctx.accounts.validate())] + pub fn handler(ctx: Context, cap: u64) -> Result<()> { + ctx.accounts.asset_config.set_inner(AssetConfig { + cap: cap, + bump: ctx.bumps.asset_config, + }); + + Ok(()) + } +} diff --git a/programs/m_ext/src/instructions/jmi/wrap_asset.rs b/programs/m_ext/src/instructions/jmi/wrap_asset.rs new file mode 100644 index 0000000..08330f5 --- /dev/null +++ b/programs/m_ext/src/instructions/jmi/wrap_asset.rs @@ -0,0 +1,159 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; + +use crate::{ + errors::ExtError, + state::{ + AssetConfig, ExtGlobalV2, ASSET_CONFIG_SEED, EXT_GLOBAL_SEED, MINT_AUTHORITY_SEED, + M_VAULT_SEED, + }, + utils::token::{mint_tokens, transfer_tokens}, +}; + +#[derive(Accounts)] +pub struct WrapAsset<'info> { + pub token_authority: Signer<'info>, + + /// Will be set if a whitelisted authority is signing for a user + pub replace_authority: Option>, + + /// Non-M asset mint (USDC, USDT, etc.) - CANNOT be M + #[account( + mint::token_program = asset_token_program, + constraint = asset_mint.key() != global_account.m_mint @ ExtError::AssetNotAllowed, + )] + pub asset_mint: InterfaceAccount<'info, Mint>, + + /// Extension token mint + #[account(mut, mint::token_program = ext_token_program)] + pub ext_mint: InterfaceAccount<'info, Mint>, + + #[account( + mut, + seeds = [EXT_GLOBAL_SEED], + bump = global_account.bump, + has_one = ext_mint @ ExtError::InvalidAccount, + )] + pub global_account: Account<'info, ExtGlobalV2>, + + #[account( + seeds = [ASSET_CONFIG_SEED, asset_mint.key().as_ref()], + bump = asset_config.bump, + )] + pub asset_config: Account<'info, AssetConfig>, + + /// CHECK: Validated by seed, used as vault authority + #[account( + seeds = [M_VAULT_SEED], + bump = global_account.m_vault_bump, + )] + pub asset_vault: AccountInfo<'info>, + + /// CHECK: Validated by seed, mint authority for ext tokens + #[account( + seeds = [MINT_AUTHORITY_SEED], + bump = global_account.ext_mint_authority_bump, + )] + pub ext_mint_authority: AccountInfo<'info>, + + /// User's asset token account (source of assets) + #[account( + mut, + token::mint = asset_mint, + token::token_program = asset_token_program, + )] + pub from_asset_token_account: InterfaceAccount<'info, TokenAccount>, + + /// Vault's asset token account (destination for assets) + #[account( + mut, + associated_token::mint = asset_mint, + associated_token::authority = asset_vault, + associated_token::token_program = asset_token_program, + )] + pub vault_asset_token_account: InterfaceAccount<'info, TokenAccount>, + + /// User's ext token account (receives minted ext tokens) + #[account( + mut, + token::mint = ext_mint, + token::token_program = ext_token_program, + )] + pub to_ext_token_account: InterfaceAccount<'info, TokenAccount>, + + pub asset_token_program: Interface<'info, TokenInterface>, + pub ext_token_program: Interface<'info, TokenInterface>, +} + +impl WrapAsset<'_> { + pub fn validate(&self, amount: u64) -> Result<()> { + let auth = match &self.replace_authority { + Some(auth) => auth.key, + None => self.token_authority.key, + }; + + // Ensure the caller is authorized + if !self.global_account.replace_authorities.contains(auth) { + return err!(ExtError::NotAuthorized); + } + + if amount == 0 { + return err!(ExtError::InvalidAmount); + } + + Ok(()) + } + + #[access_control(ctx.accounts.validate(amount))] + pub fn handler(ctx: Context, amount: u64) -> Result<()> { + let authority_seeds: &[&[&[u8]]] = &[&[ + MINT_AUTHORITY_SEED, + &[ctx.accounts.global_account.ext_mint_authority_bump], + ]]; + + // 1. Validate cap not exceeded + let new_balance = ctx + .accounts + .vault_asset_token_account + .amount + .checked_add(amount) + .ok_or(ExtError::MathOverflow)?; + + // Uninitialized asset fails at deserialization (no account exists) + // Initialized with cap = 0 caught by new_balance > cap check + if new_balance > ctx.accounts.asset_config.cap { + return err!(ExtError::AssetCapExceeded); + } + + // 2. Transfer assets from user to vault + transfer_tokens( + &ctx.accounts.from_asset_token_account, + &ctx.accounts.vault_asset_token_account, + amount, + &ctx.accounts.asset_mint, + &ctx.accounts.token_authority.to_account_info(), + &ctx.accounts.asset_token_program, + )?; + + // 3. Mint ext tokens (1:1 since all assets have 6 decimals) + mint_tokens( + &ctx.accounts.to_ext_token_account, + amount, + &ctx.accounts.ext_mint, + &ctx.accounts.ext_mint_authority, + authority_seeds, + &ctx.accounts.ext_token_program, + )?; + + // 4. Update tracking state + ctx.accounts.global_account.yield_config.total_assets = ctx + .accounts + .global_account + .yield_config + .total_assets + .checked_add(amount) + .ok_or(ExtError::MathOverflow)?; + + Ok(()) + } +} diff --git a/programs/m_ext/src/instructions/manage_wrap_authority.rs b/programs/m_ext/src/instructions/manage_wrap_authority.rs index ac640fb..ac8aa3f 100644 --- a/programs/m_ext/src/instructions/manage_wrap_authority.rs +++ b/programs/m_ext/src/instructions/manage_wrap_authority.rs @@ -15,7 +15,10 @@ pub struct AddWrapAuthority<'info> { seeds = [EXT_GLOBAL_SEED], has_one = admin @ ExtError::NotAuthorized, bump = global_account.bump, - realloc = ExtGlobalV2::size(global_account.wrap_authorities.len() + 1), + realloc = ExtGlobalV2::size( + global_account.wrap_authorities.len() + 1, + global_account.replace_authorities.len() + ), realloc::payer = admin, realloc::zero = false, )] @@ -95,7 +98,10 @@ impl RemoveWrapAuthority<'_> { .retain(|&x| !x.eq(&wrap_authority)); // Reallocate the account to remove the empty space without erasing the other data - let new_size = ExtGlobalV2::size(ctx.accounts.global_account.wrap_authorities.len()); + let new_size = ExtGlobalV2::size( + ctx.accounts.global_account.wrap_authorities.len(), + ctx.accounts.global_account.replace_authorities.len(), + ); ctx.accounts .global_account .to_account_info() diff --git a/programs/m_ext/src/instructions/migrate.rs b/programs/m_ext/src/instructions/migrate.rs index 64bb3e5..c378fa2 100644 --- a/programs/m_ext/src/instructions/migrate.rs +++ b/programs/m_ext/src/instructions/migrate.rs @@ -174,13 +174,15 @@ impl MigrateM<'_> { } else if #[cfg(feature = "no-yield")] { yield_config = YieldConfig { yield_variant: YieldVariant::NoYield, + total_assets: 0, }; } }; // Resize the global account to accommodate the new yield config // We need to take into account the current number of wrap authorities - let new_size = ExtGlobalV2::size(old_global.wrap_authorities.len()); + // replace_authorities starts empty during migration + let new_size = ExtGlobalV2::size(old_global.wrap_authorities.len(), 0); let account_info = ctx.accounts.global_account.to_account_info(); account_info.realloc(new_size, false)?; @@ -214,6 +216,7 @@ impl MigrateM<'_> { ext_mint_authority_bump: old_global.ext_mint_authority_bump, yield_config, wrap_authorities: old_global.wrap_authorities.clone(), + replace_authorities: vec![], // starts empty during migration }; // Write the new global account data diff --git a/programs/m_ext/src/instructions/mod.rs b/programs/m_ext/src/instructions/mod.rs index 2326a83..f98caf1 100644 --- a/programs/m_ext/src/instructions/mod.rs +++ b/programs/m_ext/src/instructions/mod.rs @@ -29,3 +29,11 @@ cfg_if::cfg_if!( pub use migrate::*; } ); + +// JMI-specific instructions +cfg_if::cfg_if!( + if #[cfg(feature = "jmi")] { + pub mod jmi; + pub use jmi::*; + } +); diff --git a/programs/m_ext/src/instructions/unwrap.rs b/programs/m_ext/src/instructions/unwrap.rs index c8570e8..8cd4ec4 100644 --- a/programs/m_ext/src/instructions/unwrap.rs +++ b/programs/m_ext/src/instructions/unwrap.rs @@ -1,5 +1,5 @@ use anchor_lang::prelude::*; -use anchor_spl::token_interface::{Mint, Token2022, TokenAccount, TokenInterface}; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; use crate::{ errors::ExtError, @@ -73,9 +73,7 @@ pub struct Unwrap<'info> { )] pub from_ext_token_account: InterfaceAccount<'info, TokenAccount>, - // we have duplicate entries for the token2022 program since the interface needs to be consistent - // but we want to leave open the possibility that either may not have to be token2022 in the future - pub m_token_program: Program<'info, Token2022>, + pub m_token_program: Interface<'info, TokenInterface>, pub ext_token_program: Interface<'info, TokenInterface>, } diff --git a/programs/m_ext/src/instructions/wrap.rs b/programs/m_ext/src/instructions/wrap.rs index 0994dff..57acbbd 100644 --- a/programs/m_ext/src/instructions/wrap.rs +++ b/programs/m_ext/src/instructions/wrap.rs @@ -1,15 +1,14 @@ use anchor_lang::prelude::*; -use anchor_spl::token_interface::{Mint, Token2022, TokenAccount, TokenInterface}; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; use crate::{ errors::ExtError, state::{ExtGlobalV2, EXT_GLOBAL_SEED, MINT_AUTHORITY_SEED, M_VAULT_SEED}, - utils::{ - conversion::{ - amount_to_principal_down, multiplier_to_index, principal_to_amount_down, sync_index, - }, - token::{mint_tokens, transfer_tokens}, - }, + utils::token::{mint_tokens, transfer_tokens}, +}; + +use crate::utils::conversion::{ + amount_to_principal_down, multiplier_to_index, principal_to_amount_down, sync_index, }; #[derive(Accounts)] @@ -73,9 +72,7 @@ pub struct Wrap<'info> { )] pub to_ext_token_account: InterfaceAccount<'info, TokenAccount>, - // we have duplicate entries for the token2022 program since the interface needs to be consistent - // but we want to leave open the possibility that either may not have to be token2022 in the future - pub m_token_program: Program<'info, Token2022>, + pub m_token_program: Interface<'info, TokenInterface>, pub ext_token_program: Interface<'info, TokenInterface>, } @@ -98,6 +95,7 @@ impl Wrap<'_> { Ok(()) } + // Single unified handler for all feature combinations #[access_control(ctx.accounts.validate(m_principal))] pub fn handler(ctx: Context, m_principal: u64) -> Result<()> { let authority_seeds: &[&[&[u8]]] = &[&[ @@ -107,40 +105,40 @@ impl Wrap<'_> { // If necessary, sync the index between M and Ext tokens // Return the current value to use for conversions - let ext_index: u64 = sync_index( - &mut ctx.accounts.ext_mint, - &mut ctx.accounts.global_account, - &ctx.accounts.m_mint, - &ctx.accounts.vault_m_token_account, - &ctx.accounts.ext_mint_authority, - authority_seeds, - &ctx.accounts.ext_token_program, - )?; - - // Get the current M index - let m_scaled_ui_config = - earn::utils::conversion::get_scaled_ui_config(&ctx.accounts.m_mint)?; - let m_index = multiplier_to_index(m_scaled_ui_config.new_multiplier.into())?; - - // Calculate the principal amount of ext tokens to mint - // based on the principal amount of m tokens to wrap - let ext_principal = - amount_to_principal_down(principal_to_amount_down(m_principal, m_index)?, ext_index)?; + let ext_m_principal = { + let ext_index: u64 = sync_index( + &mut ctx.accounts.ext_mint, + &mut ctx.accounts.global_account, + &ctx.accounts.m_mint, + &ctx.accounts.vault_m_token_account, + &ctx.accounts.ext_mint_authority, + authority_seeds, + &ctx.accounts.ext_token_program, + )?; + + let m_scaled_ui_config = + earn::utils::conversion::get_scaled_ui_config(&ctx.accounts.m_mint)?; + let m_index = multiplier_to_index(m_scaled_ui_config.new_multiplier.into())?; + + // Calculate the principal amount of ext tokens to mint + // based on the principal amount of m tokens to wrap + amount_to_principal_down(principal_to_amount_down(m_principal, m_index)?, ext_index)? + }; - // Transfer the amount of m tokens from the user to the m vault + // Transfer M tokens from user to vault transfer_tokens( &ctx.accounts.from_m_token_account, // from &ctx.accounts.vault_m_token_account, // to - m_principal, // amount + m_principal, // m_principal &ctx.accounts.m_mint, // mint &ctx.accounts.token_authority.to_account_info(), // authority &ctx.accounts.m_token_program, // token program )?; - // Mint the amount of ext tokens to the user + // Mint the m_principal of ext tokens to the user mint_tokens( &ctx.accounts.to_ext_token_account, // to - ext_principal, // amount + ext_m_principal, // m_principal &ctx.accounts.ext_mint, // mint &ctx.accounts.ext_mint_authority, // authority authority_seeds, // authority seeds diff --git a/programs/m_ext/src/lib.rs b/programs/m_ext/src/lib.rs index d0edb80..70f2298 100644 --- a/programs/m_ext/src/lib.rs +++ b/programs/m_ext/src/lib.rs @@ -44,6 +44,11 @@ const _: () = { "Invalid feature configuration: 'migrate' and 'crank' cannot be enabled without 'wm'" ); } + + // JMI can only be used with no-yield (not scaled-ui or crank) + if cfg!(feature = "jmi") && cfg!(not(feature = "no-yield")) { + panic!("JMI feature can only be enabled with no-yield variant"); + } }; #[program] @@ -55,23 +60,41 @@ pub mod m_ext { pub fn initialize( ctx: Context, wrap_authorities: Vec, + replace_authorities: Vec, fee_bps: u64, ) -> Result<()> { - Initialize::handler(ctx, wrap_authorities, Some(fee_bps), None) + Initialize::handler( + ctx, + wrap_authorities, + replace_authorities, + Some(fee_bps), + None, + ) } #[cfg(feature = "crank")] pub fn initialize( ctx: Context, wrap_authorities: Vec, + replace_authorities: Vec, earn_authority: Pubkey, ) -> Result<()> { - Initialize::handler(ctx, wrap_authorities, None, Some(earn_authority)) + Initialize::handler( + ctx, + wrap_authorities, + replace_authorities, + None, + Some(earn_authority), + ) } #[cfg(not(any(feature = "crank", feature = "scaled-ui")))] - pub fn initialize(ctx: Context, wrap_authorities: Vec) -> Result<()> { - Initialize::handler(ctx, wrap_authorities, None, None) + pub fn initialize( + ctx: Context, + wrap_authorities: Vec, + replace_authorities: Vec, + ) -> Result<()> { + Initialize::handler(ctx, wrap_authorities, replace_authorities, None, None) } #[cfg(feature = "scaled-ui")] @@ -93,6 +116,22 @@ pub mod m_ext { RemoveWrapAuthority::handler(ctx, wrap_authority) } + #[cfg(feature = "jmi")] + pub fn add_replace_authority( + ctx: Context, + new_replace_authority: Pubkey, + ) -> Result<()> { + AddReplaceAuthority::handler(ctx, new_replace_authority) + } + + #[cfg(feature = "jmi")] + pub fn remove_replace_authority( + ctx: Context, + replace_authority: Pubkey, + ) -> Result<()> { + RemoveReplaceAuthority::handler(ctx, replace_authority) + } + #[cfg(any(feature = "scaled-ui", feature = "no-yield"))] pub fn claim_fees(ctx: Context) -> Result<()> { ClaimFees::handler(ctx) @@ -110,6 +149,22 @@ pub mod m_ext { RevokeAdminTransfer::handler(ctx) } + // JMI-specific instructions + #[cfg(feature = "jmi")] + pub fn set_asset_cap(ctx: Context, cap: u64) -> Result<()> { + SetAssetCap::handler(ctx, cap) + } + + #[cfg(feature = "jmi")] + pub fn replace_asset_with_m(ctx: Context, m_principal: u64) -> Result<()> { + ReplaceAssetWithM::handler(ctx, m_principal) + } + + #[cfg(feature = "jmi")] + pub fn wrap_asset(ctx: Context, amount: u64) -> Result<()> { + WrapAsset::handler(ctx, amount) + } + #[cfg(feature = "crank")] pub fn set_earn_authority( ctx: Context, @@ -139,8 +194,8 @@ pub mod m_ext { // Wrap authority instructions - pub fn wrap(ctx: Context, amount: u64) -> Result<()> { - Wrap::handler(ctx, amount) + pub fn wrap(ctx: Context, m_principal: u64) -> Result<()> { + Wrap::handler(ctx, m_principal) } pub fn unwrap(ctx: Context, amount: u64) -> Result<()> { diff --git a/programs/m_ext/src/state/asset_config.rs b/programs/m_ext/src/state/asset_config.rs new file mode 100644 index 0000000..cd9d049 --- /dev/null +++ b/programs/m_ext/src/state/asset_config.rs @@ -0,0 +1,14 @@ +use anchor_lang::prelude::*; + +/// Per-asset configuration for JMI multi-asset backing +/// PDA seeds: [ASSET_CONFIG_SEED, asset_mint] +/// Note: Only assets with 6 decimals are accepted (validated in set_asset_cap) +#[account] +#[derive(InitSpace)] +pub struct AssetConfig { + /// Maximum balance allowed for this asset (in 6 decimals, 0 = disabled) + pub cap: u64, + /// PDA bump seed + pub bump: u8, +} +// Size: 8 (discriminator) + 8 + 1 = 17 bytes diff --git a/programs/m_ext/src/state/mod.rs b/programs/m_ext/src/state/mod.rs index 9efa039..5ef76d1 100644 --- a/programs/m_ext/src/state/mod.rs +++ b/programs/m_ext/src/state/mod.rs @@ -14,12 +14,13 @@ pub struct ExtGlobalV2 { pub bump: u8, pub m_vault_bump: u8, pub ext_mint_authority_bump: u8, - pub yield_config: YieldConfig, // variant specific state - pub wrap_authorities: Vec, // accounts permissioned to wrap/unwrap the ext_mint + pub yield_config: YieldConfig, // variant specific state + pub wrap_authorities: Vec, // accounts permissioned to wrap/unwrap the ext_mint + pub replace_authorities: Vec, // accounts permissioned for JMI operations (wrap_asset, replace_asset_with_m) } impl ExtGlobalV2 { - pub fn size(wrap_authorities: usize) -> usize { + pub fn size(wrap_authorities: usize, replace_authorities: usize) -> usize { 8 + // discriminator 32 + // admin 1 + 32 + // pending_admin (Option) @@ -31,7 +32,9 @@ impl ExtGlobalV2 { 1 + // ext_mint_authority_bump YieldConfig::space() + // yield_config 4 + // length of wrap_authorities vector - wrap_authorities * 32 // each Pubkey is 32 bytes + wrap_authorities * 32 + // each Pubkey is 32 bytes + 4 + // length of replace_authorities vector + replace_authorities * 32 // each Pubkey is 32 bytes } } @@ -41,6 +44,10 @@ pub const MINT_AUTHORITY_SEED: &[u8] = b"mint_authority"; #[constant] pub const M_VAULT_SEED: &[u8] = b"m_vault"; +#[cfg(feature = "jmi")] +#[constant] +pub const ASSET_CONFIG_SEED: &[u8] = b"asset_config"; + #[derive(AnchorSerialize, AnchorDeserialize, Clone)] #[repr(u8)] pub enum YieldVariant { @@ -93,15 +100,21 @@ cfg_if! { pub use earner::*; pub use earn_manager::*; } else { + // no-yield contains fields JMI fields #[derive(AnchorSerialize, AnchorDeserialize, Clone)] pub struct YieldConfig { - pub yield_variant: YieldVariant, // variant of yield config + pub yield_variant: YieldVariant, // 1 byte + pub total_assets: u64, // 8 bytes - sum of non-M assets (6 decimals) } impl YieldConfig { pub fn space() -> usize { - 1 // yield_variant + 1 + // yield_variant + 8 // total_assets } } + + pub mod asset_config; + pub use asset_config::*; } } diff --git a/programs/m_ext/src/utils/token.rs b/programs/m_ext/src/utils/token.rs index 1ce6849..61126a1 100644 --- a/programs/m_ext/src/utils/token.rs +++ b/programs/m_ext/src/utils/token.rs @@ -1,56 +1,55 @@ // external dependencies use anchor_lang::prelude::*; use anchor_spl::token_interface::{ - burn, mint_to, transfer_checked, Burn, Mint, MintTo, Token2022, TokenAccount, TokenInterface, + burn, mint_to, transfer_checked, Burn, Mint, MintTo, TokenAccount, TokenInterface, TransferChecked, }; -pub fn transfer_tokens_from_program<'info>( +/// Transfer tokens using the generic TokenInterface (supports both Token and Token2022) +pub fn transfer_tokens<'info>( from: &InterfaceAccount<'info, TokenAccount>, to: &InterfaceAccount<'info, TokenAccount>, amount: u64, mint: &InterfaceAccount<'info, Mint>, authority: &AccountInfo<'info>, - authority_seeds: &[&[&[u8]]], - token_program: &Program<'info, Token2022>, + token_program: &Interface<'info, TokenInterface>, ) -> Result<()> { - // Build the arguments for the transfer instruction let transfer_options = TransferChecked { from: from.to_account_info(), to: to.to_account_info(), mint: mint.to_account_info(), authority: authority.clone(), }; - let cpi_context = CpiContext::new_with_signer( - token_program.to_account_info(), - transfer_options, - authority_seeds, - ); + let cpi_context = CpiContext::new(token_program.to_account_info(), transfer_options); - // Call the transfer instruction transfer_checked(cpi_context, amount, mint.decimals)?; Ok(()) } -pub fn transfer_tokens<'info>( +/// Transfer tokens from a program-owned account using PDA signer +/// Supports both SPL Token and Token2022 +pub fn transfer_tokens_from_program<'info>( from: &InterfaceAccount<'info, TokenAccount>, to: &InterfaceAccount<'info, TokenAccount>, amount: u64, mint: &InterfaceAccount<'info, Mint>, authority: &AccountInfo<'info>, - token_program: &Program<'info, Token2022>, + authority_seeds: &[&[&[u8]]], + token_program: &Interface<'info, TokenInterface>, ) -> Result<()> { - // Build the arguments for the transfer instruction let transfer_options = TransferChecked { from: from.to_account_info(), to: to.to_account_info(), mint: mint.to_account_info(), authority: authority.clone(), }; - let cpi_context = CpiContext::new(token_program.to_account_info(), transfer_options); + let cpi_context = CpiContext::new_with_signer( + token_program.to_account_info(), + transfer_options, + authority_seeds, + ); - // Call the transfer instruction transfer_checked(cpi_context, amount, mint.decimals)?; Ok(()) diff --git a/tests/programs/ext_a.so b/tests/programs/ext_a.so index f4361e9..96b520b 100755 Binary files a/tests/programs/ext_a.so and b/tests/programs/ext_a.so differ diff --git a/tests/programs/ext_b.so b/tests/programs/ext_b.so index 149f2bf..99b268f 100755 Binary files a/tests/programs/ext_b.so and b/tests/programs/ext_b.so differ diff --git a/tests/programs/ext_c.so b/tests/programs/ext_c.so index af0f997..19da154 100755 Binary files a/tests/programs/ext_c.so and b/tests/programs/ext_c.so differ diff --git a/tests/unit/ext_swap.test.ts b/tests/unit/ext_swap.test.ts index dd6c823..fb5eb8f 100644 --- a/tests/unit/ext_swap.test.ts +++ b/tests/unit/ext_swap.test.ts @@ -2,6 +2,7 @@ import { BN } from "@coral-xyz/anchor"; import { PublicKey, Keypair, LAMPORTS_PER_SOL } from "@solana/web3.js"; import { TOKEN_2022_PROGRAM_ID, + TOKEN_PROGRAM_ID, getAssociatedTokenAddressSync, } from "@solana/spl-token"; import { ExtensionSwapTest } from "./ext_test_harness"; @@ -279,6 +280,13 @@ describe("extension swap tests (new)", () => { }); describe("error cases", () => { + // [x] given extension is not whitelisted + // [x] it reverts + // [x] given swap amount is 0 + // [x] it reverts + // [x] given swapping to same extension + // [x] it reverts with constraint violation + it("should fail when extension is not whitelisted", async () => { // Remove extension C from whitelist first await $.swapProgram.methods @@ -336,6 +344,32 @@ describe("extension swap tests (new)", () => { .rpc() ).rejects.toThrow(); }); + + it("should fail when swapping to same extension", async () => { + const accounts = await getTokenAccounts(); + + await expect( + $.swapProgram.methods + .swap(new BN(100), 0) + .accounts({ + signer: $.swapperKeypair.publicKey, + unwrapAuthority: $.swapProgram.programId, + wrapAuthority: $.swapProgram.programId, + mMint: $.mMint.publicKey, + mTokenProgram: TOKEN_2022_PROGRAM_ID, + fromExtProgram: $.getExtensionProgramId("extA"), + toExtProgram: $.getExtensionProgramId("extA"), // Same as from + fromMint: $.getExtensionMint("mintA"), + toMint: $.getExtensionMint("mintA"), + fromTokenAccount: accounts.ataA, + toTokenAccount: accounts.ataA, + toTokenProgram: TOKEN_2022_PROGRAM_ID, + fromTokenProgram: TOKEN_2022_PROGRAM_ID, + }) + .signers([$.swapperKeypair]) + .rpc() + ).rejects.toThrow(); // Anchor constraint violation + }); }); describe("remaining accounts tests", () => { @@ -590,6 +624,10 @@ describe("extension swap tests (new)", () => { .rpc(), "NotAuthorized" ); + + // re-add swap program as wrap authority for later tests + $.svm.expireBlockhash(); + await $.addWrapAuthorityToExtension("extA", swapGlobal); }); it("should fail to wrap with invalid external ext wrap authority co-signer", async () => { @@ -641,6 +679,16 @@ describe("extension swap tests (new)", () => { it("should fail swap with mismatched authorities", async () => { const accounts = await getTokenAccounts(); + // Remove swap program as wrap authority from extension A + const swapGlobal = $.getSwapGlobalAccount(); + await $.extensionPrograms.extA.methods + .removeWrapAuthority(swapGlobal) + .accounts({ + admin: $.admin.publicKey, + }) + .signers([$.admin]) + .rpc(); + await expect( $.swapProgram.methods .swap(new BN(15), 0) @@ -662,6 +710,10 @@ describe("extension swap tests (new)", () => { .signers([$.swapperKeypair, $.admin]) .rpc() ).rejects.toThrow(); + + // re-add swap program as wrap authority for later tests + $.svm.expireBlockhash(); + await $.addWrapAuthorityToExtension("extA", swapGlobal); }); it("should swap with valid wrap authority", async () => { @@ -812,4 +864,580 @@ describe("extension swap tests (new)", () => { .rpc(); }); }); + + // ========================================================================== + // JMI (Just Mint It) Test Cases + // ========================================================================== + + describe("wrap_asset operations", () => { + // ext_swap level validation + // [x] given the extension is not whitelisted + // [x] it reverts with an InvalidExtension error + // [x] given amount is 0 + // [x] it reverts with an InvalidAmount error + // [x] given the caller is not authorized (not in wrap_authorities) + // [x] it reverts with a NotAuthorized error + // [x] given the asset is M (not a stablecoin) + // [x] it reverts with an AssetNotAllowed error + // [x] given the asset_config.cap is 0 (asset not whitelisted) + // [x] it reverts with an AccountNotInitialized error + // [x] given new_balance would exceed asset_config.cap + // [x] it reverts with an AssetCapExceeded error + // [x] given replace_authority is not provided (None) + // [x] it uses the fallback_replace_authority PDA + // [x] it transfers asset from user to vault + // [x] it mints ext tokens 1:1 to user + // [x] it increments total_assets on the global account + // [x] given replace_authority is provided (Some) + // [x] it uses the external replace_authority signer + // [x] given external replace_authority is provided but does not sign + // [x] it reverts with Signer constraint violation + + let assetMint: Keypair; + const wrapAssetCap = new BN(1_000_000_000); + const wrapAssetAmount = new BN(100_000); + + beforeAll(async () => { + // Create asset mint (stablecoin) + assetMint = await $.createAssetMint(); + + // Set asset cap on JMI extension (extA) + await $.setAssetCapOnExtension("extA", assetMint.publicKey, wrapAssetCap); + + // Whitelist replace authority PDA on extension's replace_authorities + await $.addReplaceAuthorityToExtension("extA", $.getReplaceAuthorityPda()); + + // Mint asset tokens to swapper + await $.mintAssetTokensTo( + assetMint, + $.swapperKeypair.publicKey, + wrapAssetAmount.mul(new BN(10)) + ); + }); + + it("should fail when extension is not whitelisted", async () => { + // Create a new asset mint that's not whitelisted on ext_swap + const newAssetMint = await $.createAssetMint(); + await $.setAssetCapOnExtension("extA", newAssetMint.publicKey, wrapAssetCap); + await $.mintAssetTokensTo(newAssetMint, $.swapperKeypair.publicKey, wrapAssetAmount); + + // Remove extA from swap whitelist + await $.swapProgram.methods + .removeWhitelistedExtension($.getExtensionProgramId("extA")) + .accounts({ admin: $.admin.publicKey }) + .signers([$.admin]) + .rpc(); + + await $.expectAnchorError( + $.wrapAssetViaSwap("extA", newAssetMint.publicKey, wrapAssetAmount, $.swapperKeypair), + "InvalidExtension" + ); + + // Re-add for later tests + $.svm.expireBlockhash(); + await $.whitelistExtension($.getExtensionProgramId("extA"), $.getExtensionMint("mintA")); + }); + + it("should fail when amount is zero", async () => { + await $.expectAnchorError( + $.wrapAssetViaSwap("extA", assetMint.publicKey, new BN(0), $.swapperKeypair), + "InvalidAmount" + ); + }); + + it("should fail when fallback_replace_authority is not authorized", async () => { + // Remove fallback_replace_authority PDA from extA's replace_authorities + await $.extensionPrograms.extA.methods + .removeReplaceAuthority($.getReplaceAuthorityPda()) + .accounts({ admin: $.admin.publicKey }) + .signers([$.admin]) + .rpc(); + + await $.expectAnchorError( + $.wrapAssetViaSwap("extA", assetMint.publicKey, wrapAssetAmount, $.swapperKeypair), + "NotAuthorized" + ); + + // Re-add for later tests + $.svm.expireBlockhash(); + await $.addReplaceAuthorityToExtension("extA", $.getReplaceAuthorityPda()); + }); + + it("should fail when asset is M token", async () => { + // Attempt to use M mint as asset - this should fail because M is not a valid asset + // The CPI to wrap_asset will fail with AssetNotAllowed since asset_config doesn't exist for M + await $.expectSystemError( + $.wrapAssetViaSwap("extA", $.mMint.publicKey, wrapAssetAmount, $.swapperKeypair) + ); + }); + + it("should fail when asset cap is zero", async () => { + // Create new asset without setting cap (or set cap to 0) + const uncappedAsset = await $.createAssetMint(); + await $.mintAssetTokensTo(uncappedAsset, $.swapperKeypair.publicKey, wrapAssetAmount); + + // Asset config doesn't exist, so this should fail + await $.expectAnchorError( + $.wrapAssetViaSwap("extA", uncappedAsset.publicKey, wrapAssetAmount, $.swapperKeypair), + "AccountNotInitialized" + ); + + }); + + it("should fail when asset cap would be exceeded", async () => { + // Create asset with low cap + const lowCapAsset = await $.createAssetMint(); + await $.setAssetCapOnExtension("extA", lowCapAsset.publicKey, new BN(1000)); + await $.mintAssetTokensTo(lowCapAsset, $.swapperKeypair.publicKey, new BN(2000)); + + await $.expectAnchorError( + $.wrapAssetViaSwap("extA", lowCapAsset.publicKey, new BN(1500), $.swapperKeypair), + "AssetCapExceeded" + ); + }); + + it("should wrap asset with fallback replace authority", async () => { + const userAssetAta = await $.getATA(assetMint.publicKey, $.swapperKeypair.publicKey, false); + const userExtAta = await $.getATA($.getExtensionMint("mintA"), $.swapperKeypair.publicKey); + const mVault = $.getMVaultForExtension($.getExtensionProgramId("extA")); + const vaultAssetAta = await $.getATA(assetMint.publicKey, mVault, false); + + const initialUserAssetBalance = await $.getTokenBalance(userAssetAta, false); + const initialVaultAssetBalance = await $.getTokenBalance(vaultAssetAta, false); + + await $.wrapAssetViaSwap("extA", assetMint.publicKey, wrapAssetAmount, $.swapperKeypair); + + // Verify asset transferred from user to vault + await $.expectTokenBalance(userAssetAta, initialUserAssetBalance.sub(wrapAssetAmount), undefined, undefined, false); + await $.expectTokenBalance(vaultAssetAta, initialVaultAssetBalance.add(wrapAssetAmount), undefined, undefined, false); + + // Verify ext tokens minted to user (1:1) + const userExtBalance = await $.getTokenBalance(userExtAta); + expect(userExtBalance.gte(wrapAssetAmount)).toBe(true); + }); + + it("should wrap asset with external replace authority", async () => { + const userAssetAta = await $.getATA(assetMint.publicKey, $.swapperKeypair.publicKey, false); + const initialUserAssetBalance = await $.getTokenBalance(userAssetAta, false); + + await $.wrapAssetViaSwap( + "extA", + assetMint.publicKey, + wrapAssetAmount, + $.swapperKeypair, + $.admin // external replace authority + ); + + // Verify wrap succeeded + await $.expectTokenBalance(userAssetAta, initialUserAssetBalance.sub(wrapAssetAmount), undefined, undefined, false); + }); + + it("should increment total_assets on wrap_asset", async () => { + // Fetch initial total_assets from extA global account + const jmiGlobalPda = $.getExtGlobalAccountFor("extA"); + const initialGlobal = await $.extensionPrograms.extA.account.extGlobalV2.fetch(jmiGlobalPda); + const initialTotalAssets = (initialGlobal.yieldConfig as any).totalAssets as BN; + + // Wrap asset + const wrapAmount = new BN(5_000); + await $.wrapAssetViaSwap("extA", assetMint.publicKey, wrapAmount, $.swapperKeypair); + + // Verify total_assets incremented by exact amount + const finalGlobal = await $.extensionPrograms.extA.account.extGlobalV2.fetch(jmiGlobalPda); + const finalTotalAssets = (finalGlobal.yieldConfig as any).totalAssets as BN; + expect(finalTotalAssets.eq(initialTotalAssets.add(wrapAmount))).toBe(true); + }); + + it("should fail when external replace_authority does not sign", async () => { + const userAssetAta = await $.getATA(assetMint.publicKey, $.swapperKeypair.publicKey, false); + const userExtAta = await $.getATA($.getExtensionMint("mintA"), $.swapperKeypair.publicKey); + const mVault = $.getMVaultForExtension($.getExtensionProgramId("extA")); + const vaultAssetAta = await $.getATA(assetMint.publicKey, mVault, false); + const toMintAuthority = PublicKey.findProgramAddressSync( + [Buffer.from("mint_authority")], + $.getExtensionProgramId("extA") + )[0]; + + // Try to use admin as replace_authority but don't include them as signer + await expect( + $.swapProgram.methods + .wrapAsset(wrapAssetAmount) + .accountsPartial({ + signer: $.swapperKeypair.publicKey, + replaceAuthority: $.admin.publicKey, // External authority provided + fallbackReplaceAuthority: $.getReplaceAuthorityPda(), + swapGlobal: $.getSwapGlobalAccount(), + toGlobal: $.getExtGlobalAccountFor("extA"), + toMint: $.getExtensionMint("mintA"), + assetMint: assetMint.publicKey, + assetConfig: $.getAssetConfigAccount("extA", assetMint.publicKey), + assetTokenAccount: userAssetAta, + toTokenAccount: userExtAta, + toVaultAuth: mVault, + toMintAuthority: toMintAuthority, + toAssetVault: vaultAssetAta, + toTokenProgram: TOKEN_2022_PROGRAM_ID, + assetTokenProgram: TOKEN_PROGRAM_ID, + toExtProgram: $.getExtensionProgramId("extA"), + }) + .signers([$.swapperKeypair]) // Note: $.admin NOT included as signer + .rpc() + ).rejects.toThrow(); // Anchor Signer constraint violation + }); + }); + + describe("replace_asset_with_m operations", () => { + // ext_swap level validation + // [x] given the source extension (from_ext) is not whitelisted + // [x] it reverts with an InvalidExtension error + // [x] given the JMI extension (jmi_ext) is not whitelisted + // [x] it reverts with an InvalidExtension error + // [x] given from_principal is 0 + // [x] it reverts with an InvalidAmount error + // [x] given the caller is not authorized on source extension + // [x] it reverts with a NotAuthorized error + // [x] given the caller is not authorized (not in wrap_authorities) + // [x] it reverts with a NotAuthorized error + // [x] given the vault has insufficient asset backing for the conversion + // [x] it reverts with an InsufficientAssetBacking error + // [x] given valid inputs + // [x] it burns ext tokens from user (via unwrap) + // [x] it converts M amount to asset amount using M index (rounds down) + // [x] it transfers M from swap_m_account to JMI M vault + // [x] it transfers asset from JMI asset vault to user + // [x] it decrements total_assets on the JMI global account + + let unwrapAssetMint: Keypair; + const unwrapAssetCap = new BN(1_000_000_000); + const unwrapAssetAmount = new BN(50_000); + + beforeAll(async () => { + // Setup for replace_asset_with_m tests: + // 1. Create and configure asset on JMI extension (extA) + unwrapAssetMint = await $.createAssetMint(); + await $.setAssetCapOnExtension("extA", unwrapAssetMint.publicKey, unwrapAssetCap); + + // 2. Mint assets and wrap them to populate JMI vault + await $.mintAssetTokensTo(unwrapAssetMint, $.swapperKeypair.publicKey, unwrapAssetAmount.mul(new BN(5))); + await $.wrapAssetViaSwap("extA", unwrapAssetMint.publicKey, unwrapAssetAmount.mul(new BN(3)), $.swapperKeypair); + + // 3. Give swapper some extB tokens (wrap M to extB) + const accounts = await getTokenAccounts(); + await $.swapProgram.methods + .wrap(unwrapAssetAmount) + .accounts({ + signer: $.swapperKeypair.publicKey, + wrapAuthority: $.swapProgram.programId, + mMint: $.mMint.publicKey, + mTokenAccount: accounts.ataM, + mTokenProgram: TOKEN_2022_PROGRAM_ID, + toExtProgram: $.getExtensionProgramId("extB"), + toMint: $.getExtensionMint("mintB"), + toTokenAccount: accounts.ataB, + toTokenProgram: TOKEN_2022_PROGRAM_ID, + }) + .signers([$.swapperKeypair]) + .rpc(); + + // 4. Add replace authority to extB for replace_asset_with_m tests + await $.addWrapAuthorityToExtension("extB", $.getReplaceAuthorityPda()); + }); + + it("should fail when source extension is not whitelisted", async () => { + // Remove source extension from whitelist + await $.swapProgram.methods + .removeWhitelistedExtension($.getExtensionProgramId("extB")) + .accounts({ admin: $.admin.publicKey }) + .signers([$.admin]) + .rpc(); + + await $.expectAnchorError( + $.replaceAssetWithMViaSwap("extB", "extA", unwrapAssetMint.publicKey, unwrapAssetAmount, $.swapperKeypair), + "InvalidExtension" + ); + + // Re-add for later tests + $.svm.expireBlockhash(); + await $.whitelistExtension($.getExtensionProgramId("extB"), $.getExtensionMint("mintB")); + }); + + it("should fail when JMI extension is not whitelisted", async () => { + // Remove JMI extension from whitelist + await $.swapProgram.methods + .removeWhitelistedExtension($.getExtensionProgramId("extA")) + .accounts({ admin: $.admin.publicKey }) + .signers([$.admin]) + .rpc(); + + await $.expectAnchorError( + $.replaceAssetWithMViaSwap("extB", "extA", unwrapAssetMint.publicKey, unwrapAssetAmount, $.swapperKeypair), + "InvalidExtension" + ); + + // Re-add for later tests + $.svm.expireBlockhash(); + await $.whitelistExtension($.getExtensionProgramId("extA"), $.getExtensionMint("mintA")); + }); + + it("should fail when from_principal is zero", async () => { + await $.expectAnchorError( + $.replaceAssetWithMViaSwap("extB", "extA", unwrapAssetMint.publicKey, new BN(0), $.swapperKeypair), + "InvalidAmount" + ); + }); + + it("should fail when fallback_replace_authority not authorized on source extension", async () => { + // Remove fallback_replace_authority PDA from source extension's wrap_authorities + await $.extensionPrograms.extB.methods + .removeWrapAuthority($.getReplaceAuthorityPda()) + .accounts({ admin: $.admin.publicKey }) + .signers([$.admin]) + .rpc(); + + await $.expectAnchorError( + $.replaceAssetWithMViaSwap("extB", "extA", unwrapAssetMint.publicKey, unwrapAssetAmount, $.swapperKeypair), + "NotAuthorized" + ); + + // Re-add for later tests + $.svm.expireBlockhash(); + await $.addWrapAuthorityToExtension("extB", $.getReplaceAuthorityPda()); + }); + + it("should fail when fallback_replace_authority not authorized on JMI extension", async () => { + // Remove fallback_replace_authority PDA from JMI extension's replace_authorities + await $.extensionPrograms.extA.methods + .removeReplaceAuthority($.getReplaceAuthorityPda()) + .accounts({ admin: $.admin.publicKey }) + .signers([$.admin]) + .rpc(); + + await $.expectAnchorError( + $.replaceAssetWithMViaSwap("extB", "extA", unwrapAssetMint.publicKey, unwrapAssetAmount, $.swapperKeypair), + "NotAuthorized" + ); + + // Re-add for later tests + $.svm.expireBlockhash(); + await $.addReplaceAuthorityToExtension("extA", $.getReplaceAuthorityPda()); + }); + + it("should fail when insufficient asset backing", async () => { + // Create a new asset with minimal backing in vault + const lowBackingAsset = await $.createAssetMint(); + await $.setAssetCapOnExtension("extA", lowBackingAsset.publicKey, unwrapAssetCap); + await $.mintAssetTokensTo(lowBackingAsset, $.swapperKeypair.publicKey, new BN(100)); + // Wrap only a small amount + await $.wrapAssetViaSwap("extA", lowBackingAsset.publicKey, new BN(50), $.swapperKeypair); + + // Try to unwrap more than what's backed (vault only has 50 assets) + await $.expectAnchorError( + $.replaceAssetWithMViaSwap("extB", "extA", lowBackingAsset.publicKey, unwrapAssetAmount, $.swapperKeypair), + "InsufficientAssetBacking" + ); + }); + + it("should unwrap asset successfully", async () => { + const userAssetAta = await $.getATA(unwrapAssetMint.publicKey, $.swapperKeypair.publicKey, false); + const userExtBAta = await $.getATA($.getExtensionMint("mintB"), $.swapperKeypair.publicKey); + const jmiMVault = $.getMVaultForExtension($.getExtensionProgramId("extA")); + const jmiAssetVault = await $.getATA(unwrapAssetMint.publicKey, jmiMVault, false); + + const initialUserAssetBalance = await $.getTokenBalance(userAssetAta, false); + const initialUserExtBBalance = await $.getTokenBalance(userExtBAta); + const initialVaultAssetBalance = await $.getTokenBalance(jmiAssetVault, false); + + const unwrapAmount = new BN(1_000); + await $.replaceAssetWithMViaSwap("extB", "extA", unwrapAssetMint.publicKey, unwrapAmount, $.swapperKeypair); + + // Verify ext tokens burned from user + const finalUserExtBBalance = await $.getTokenBalance(userExtBAta); + expect(finalUserExtBBalance.lt(initialUserExtBBalance)).toBe(true); + + // Verify asset transferred to user + const finalUserAssetBalance = await $.getTokenBalance(userAssetAta, false); + expect(finalUserAssetBalance.gt(initialUserAssetBalance)).toBe(true); + + // Verify asset removed from vault + const finalVaultAssetBalance = await $.getTokenBalance(jmiAssetVault, false); + expect(finalVaultAssetBalance.lt(initialVaultAssetBalance)).toBe(true); + }); + + it("should transfer M to JMI M vault", async () => { + // Get JMI M vault token account + const jmiMVaultAuth = $.getMVaultForExtension($.getExtensionProgramId("extA")); + const jmiMVaultAta = await $.getATA($.mMint.publicKey, jmiMVaultAuth); + + // Get initial balance + const initialJmiMVaultBalance = await $.getTokenBalance(jmiMVaultAta); + + // Perform replace_asset_with_m (extB → asset via extA's JMI) + const unwrapAmount = new BN(500); + await $.replaceAssetWithMViaSwap("extB", "extA", unwrapAssetMint.publicKey, unwrapAmount, $.swapperKeypair); + + // Verify M transferred to JMI vault + const finalJmiMVaultBalance = await $.getTokenBalance(jmiMVaultAta); + expect(finalJmiMVaultBalance.gt(initialJmiMVaultBalance)).toBe(true); + }); + }); + + describe("wrap_asset and replace_asset_with_m integration", () => { + // [x] given M backing is insufficient on source extension + // [x] it reverts with an InsufficientMBacking error + // [x] given valid signer and inputs and sufficent backing + // [x] users m_ext bal less than before + // [x] users asset bal greater than before + // [x] should correctly track total_assets through multiple operations + // [x] verifies vault balance decreases correctly + // [x] verifies total_assets decreases by exact asset amount transferred + // [x] confirms total_assets stays consistent with actual vault holdings + + let integrationAssetMint: Keypair; + const integrationCap = new BN(10_000_000); + const integrationAmount = new BN(100_000); + + beforeAll(async () => { + // Setup for integration tests + integrationAssetMint = await $.createAssetMint(); + await $.setAssetCapOnExtension("extA", integrationAssetMint.publicKey, integrationCap); + // Add fallback_replace_authority to extA's wrap_authorities for same-extension unwrap + await $.addWrapAuthorityToExtension("extA", $.getReplaceAuthorityPda()); + await $.mintAssetTokensTo(integrationAssetMint, $.swapperKeypair.publicKey, integrationAmount.mul(new BN(10))); + }); + + it("should revert replace_asset_with_m when M backing is insufficient (same-extension)", async () => { + const userAssetAta = await $.getATA(integrationAssetMint.publicKey, $.swapperKeypair.publicKey, false); + const userExtAAta = await $.getATA($.getExtensionMint("mintA"), $.swapperKeypair.publicKey); + + // Wrap asset to get extA tokens (these are asset-backed, no M in vault) + await $.wrapAssetViaSwap("extA", integrationAssetMint.publicKey, integrationAmount, $.swapperKeypair); + + // Verify ext tokens received + const extABalance = await $.getTokenBalance(userExtAAta); + expect(extABalance.gte(integrationAmount)).toBe(true); + + // Attempt to replace_asset_with_m with same extension (extA → extA) + // Should fail because asset-backed tokens have no M backing + // This matches EVM behavior where totalAssets == totalSupply means M backing = 0 + const unwrapAmount = integrationAmount.div(new BN(2)); + + await expect( + $.replaceAssetWithMViaSwap( + "extA", // from extension + "extA", // JMI extension (same) + integrationAssetMint.publicKey, + unwrapAmount, + $.swapperKeypair + ) + ).rejects.toThrow(/insufficient funds/i); + }); + + it("should replace_asset_with_m when source extension has sufficient M backing", async () => { + // Setup: Give swapper M-backed extB tokens (wrap M → extB) + const accounts = await getTokenAccounts(); + const wrapMAmount = new BN(50_000); + + await $.swapProgram.methods + .wrap(wrapMAmount) + .accounts({ + signer: $.swapperKeypair.publicKey, + wrapAuthority: $.swapProgram.programId, + mMint: $.mMint.publicKey, + mTokenAccount: accounts.ataM, + mTokenProgram: TOKEN_2022_PROGRAM_ID, + toExtProgram: $.getExtensionProgramId("extB"), + toMint: $.getExtensionMint("mintB"), + toTokenAccount: accounts.ataB, + toTokenProgram: TOKEN_2022_PROGRAM_ID, + }) + .signers([$.swapperKeypair]) + .rpc(); + + // Now extB has M backing. Unwrap extB → get assets from extA's JMI vault + const userAssetAta = await $.getATA(integrationAssetMint.publicKey, $.swapperKeypair.publicKey, false); + const initialAssetBalance = await $.getTokenBalance(userAssetAta, false); + const userExtBBalance = await $.getTokenBalance(accounts.ataB); + + const unwrapAmount = wrapMAmount.div(new BN(2)); + + await $.replaceAssetWithMViaSwap( + "extB", // from extension (has M backing) + "extA", // JMI extension (has assets in vault from previous wrap_asset) + integrationAssetMint.publicKey, + unwrapAmount, + $.swapperKeypair + ); + + // Verify: extB tokens burned, assets received + const finalExtBBalance = await $.getTokenBalance(accounts.ataB); + expect(finalExtBBalance.lt(userExtBBalance)).toBe(true); + + const finalAssetBalance = await $.getTokenBalance(userAssetAta, false); + expect(finalAssetBalance.gt(initialAssetBalance)).toBe(true); + }); + + it("should correctly track total_assets through multiple operations", async () => { + // Create a fresh asset for clean tracking + const trackingAsset = await $.createAssetMint(); + await $.setAssetCapOnExtension("extA", trackingAsset.publicKey, integrationCap); + await $.mintAssetTokensTo(trackingAsset, $.swapperKeypair.publicKey, integrationAmount.mul(new BN(5))); + + const jmiMVault = $.getMVaultForExtension($.getExtensionProgramId("extA")); + const vaultAssetAta = await $.getATA(trackingAsset.publicKey, jmiMVault, false); + const jmiGlobalPda = $.getExtGlobalAccountFor("extA"); + + // Get initial vault balance and total_assets + const initialVaultBalance = await $.getTokenBalance(vaultAssetAta, false); + const initialGlobalAccount = await $.extensionPrograms.extA.account.extGlobalV2.fetch(jmiGlobalPda); + const initialTotalAssets = (initialGlobalAccount.yieldConfig as any).totalAssets as BN; + + // Wrap multiple times + const wrapAmount1 = new BN(10_000); + const wrapAmount2 = new BN(20_000); + + await $.wrapAssetViaSwap("extA", trackingAsset.publicKey, wrapAmount1, $.swapperKeypair); + $.svm.expireBlockhash(); + await $.wrapAssetViaSwap("extA", trackingAsset.publicKey, wrapAmount2, $.swapperKeypair); + + // Verify vault balance increased correctly + const afterWrapsBalance = await $.getTokenBalance(vaultAssetAta, false); + const expectedAfterWraps = initialVaultBalance.add(wrapAmount1).add(wrapAmount2); + expect(afterWrapsBalance.eq(expectedAfterWraps)).toBe(true); + + // Verify total_assets increased correctly + const afterWrapsGlobalAccount = await $.extensionPrograms.extA.account.extGlobalV2.fetch(jmiGlobalPda); + const afterWrapsTotalAssets = (afterWrapsGlobalAccount.yieldConfig as any).totalAssets as BN; + expect(afterWrapsTotalAssets.eq(initialTotalAssets.add(wrapAmount1).add(wrapAmount2))).toBe(true); + + // Confirm total_assets increase matches vault balance increase + const vaultBalanceIncrease = afterWrapsBalance.sub(initialVaultBalance); + const totalAssetsIncrease = afterWrapsTotalAssets.sub(initialTotalAssets); + expect(totalAssetsIncrease.eq(vaultBalanceIncrease)).toBe(true); + + // Now unwrap some via replace_asset_with_m (need extB tokens first) + const accounts = await getTokenAccounts(); + const userExtBAta = accounts.ataB; + const extBBalance = await $.getTokenBalance(userExtBAta); + + if (extBBalance.gt(new BN(0))) { + const unwrapAmount = BN.min(extBBalance, new BN(5_000)); + await $.replaceAssetWithMViaSwap("extB", "extA", trackingAsset.publicKey, unwrapAmount, $.swapperKeypair); + + // Verify vault balance decreased + const finalVaultBalance = await $.getTokenBalance(vaultAssetAta, false); + expect(finalVaultBalance.lt(afterWrapsBalance)).toBe(true); + + // Verify total_assets decreases by exact asset amount transferred + const finalGlobalAccount = await $.extensionPrograms.extA.account.extGlobalV2.fetch(jmiGlobalPda); + const finalTotalAssets = (finalGlobalAccount.yieldConfig as any).totalAssets as BN; + const assetsTransferred = afterWrapsBalance.sub(finalVaultBalance); + expect(finalTotalAssets.eq(afterWrapsTotalAssets.sub(assetsTransferred))).toBe(true); + + // Confirm total_assets decrease matches vault balance decrease + const vaultBalanceDecrease = afterWrapsBalance.sub(finalVaultBalance); + const totalAssetsDecrease = afterWrapsTotalAssets.sub(finalTotalAssets); + expect(totalAssetsDecrease.eq(vaultBalanceDecrease)).toBe(true); + } + }); + }); }); diff --git a/tests/unit/ext_test_harness.ts b/tests/unit/ext_test_harness.ts index 3624011..0ab7d40 100644 --- a/tests/unit/ext_test_harness.ts +++ b/tests/unit/ext_test_harness.ts @@ -36,6 +36,10 @@ import { getExtensionData, createApproveCheckedInstruction, createFreezeAccountInstruction, + createInitializeTransferFeeConfigInstruction, + createInitializeNonTransferableMintInstruction, + createInitializeInterestBearingMintInstruction, + pause, } from "@solana/spl-token"; import { ZERO_WORD, @@ -46,8 +50,8 @@ import { ProofElement, } from "../test-utils"; import { MExt as ScaledUIExt } from "../../target/types/scaled_ui"; -import { MExt as NoYieldExt } from "../../target/types/no_yield"; import { MExt as CrankExt } from "../../target/types/crank"; +import { MExt as JmiExt } from "../../target/types/jmi"; import { Earn } from "../programs/earn"; import { ExtSwap } from "../../target/types/ext_swap"; @@ -62,12 +66,12 @@ export enum Comparison { // Type definitions for accounts to make it easier to do comparisons export enum Variant { - NoYield = "no_yield", ScaledUi = "scaled_ui", Crank = "crank", + Jmi = "jmi", } -type MExt = NoYieldExt | ScaledUIExt | CrankExt; +type MExt = ScaledUIExt | CrankExt | JmiExt; type YieldVariant = { noYield: {} } | { scaledUi: {} } | { crank: {} }; @@ -88,6 +92,7 @@ export type YieldConfig = V extends Variant.ScaledUi } : { yieldVariant?: YieldVariant; + totalAssets?: BN; }; export type ExtGlobal = { @@ -100,6 +105,7 @@ export type ExtGlobal = { mVaultBump?: number; extMintAuthorityBump?: number; wrapAuthorities?: PublicKey[]; + replaceAuthorities?: PublicKey[]; yieldConfig?: YieldConfig; }; @@ -111,6 +117,11 @@ export type EarnManager = { bump?: number; }; +export type AssetConfig = { + cap?: BN; + bump?: number; +}; + export type Earner = { lastClaimIndex?: BN; lastClaimTimestamp?: BN; @@ -591,6 +602,127 @@ class ExtensionTestBase { return mint.publicKey; } + public async createTransferFeeMint( + mint: Keypair, + mintAuthority: PublicKey, + freezeAuthority: PublicKey | null, + decimals = 6 + ) { + const tokenProgram = TOKEN_2022_PROGRAM_ID; + const mintLen = getMintLen([ExtensionType.TransferFeeConfig]); + const mintLamports = + await this.provider.connection.getMinimumBalanceForRentExemption(mintLen); + + const createMintAccount = SystemProgram.createAccount({ + fromPubkey: this.admin.publicKey, + newAccountPubkey: mint.publicKey, + space: mintLen, + lamports: mintLamports, + programId: tokenProgram, + }); + + const initializeTransferFee = createInitializeTransferFeeConfigInstruction( + mint.publicKey, + mintAuthority, // fee config authority + mintAuthority, // withdraw withheld authority + 500, // 5% fee in basis points + BigInt(1_000_000), // max fee + tokenProgram + ); + + const initializeMint = createInitializeMintInstruction( + mint.publicKey, + decimals, + mintAuthority, + freezeAuthority, + tokenProgram + ); + + let tx = new Transaction(); + tx.add(createMintAccount, initializeTransferFee, initializeMint); + await this.provider.sendAndConfirm!(tx, [this.admin, mint]); + + return mint.publicKey; + } + + public async createNonTransferableMint( + mint: Keypair, + mintAuthority: PublicKey, + freezeAuthority: PublicKey | null, + decimals = 6 + ) { + const tokenProgram = TOKEN_2022_PROGRAM_ID; + const mintLen = getMintLen([ExtensionType.NonTransferable]); + const mintLamports = + await this.provider.connection.getMinimumBalanceForRentExemption(mintLen); + + const createMintAccount = SystemProgram.createAccount({ + fromPubkey: this.admin.publicKey, + newAccountPubkey: mint.publicKey, + space: mintLen, + lamports: mintLamports, + programId: tokenProgram, + }); + + const initializeNonTransferable = + createInitializeNonTransferableMintInstruction(mint.publicKey, tokenProgram); + + const initializeMint = createInitializeMintInstruction( + mint.publicKey, + decimals, + mintAuthority, + freezeAuthority, + tokenProgram + ); + + let tx = new Transaction(); + tx.add(createMintAccount, initializeNonTransferable, initializeMint); + await this.provider.sendAndConfirm!(tx, [this.admin, mint]); + + return mint.publicKey; + } + + public async createInterestBearingMint( + mint: Keypair, + mintAuthority: PublicKey, + freezeAuthority: PublicKey | null, + decimals = 6 + ) { + const tokenProgram = TOKEN_2022_PROGRAM_ID; + const mintLen = getMintLen([ExtensionType.InterestBearingConfig]); + const mintLamports = + await this.provider.connection.getMinimumBalanceForRentExemption(mintLen); + + const createMintAccount = SystemProgram.createAccount({ + fromPubkey: this.admin.publicKey, + newAccountPubkey: mint.publicKey, + space: mintLen, + lamports: mintLamports, + programId: tokenProgram, + }); + + const initializeInterestBearing = createInitializeInterestBearingMintInstruction( + mint.publicKey, + mintAuthority, // rate authority + 100, // 1% interest rate (basis points) + tokenProgram + ); + + const initializeMint = createInitializeMintInstruction( + mint.publicKey, + decimals, + mintAuthority, + freezeAuthority, + tokenProgram + ); + + let tx = new Transaction(); + tx.add(createMintAccount, initializeInterestBearing, initializeMint); + await this.provider.sendAndConfirm!(tx, [this.admin, mint]); + + return mint.publicKey; + } + public async getScaledUiAmountConfig( mint: PublicKey ): Promise { @@ -1160,7 +1292,7 @@ export class ExtensionTest< // Create the Ext token mint switch (this.variant) { - case Variant.NoYield: + case Variant.Jmi: await this.createMint( this.extMint, this.getExtMintAuthority(), @@ -1268,7 +1400,7 @@ export class ExtensionTest< } public async getCurrentExtIndex(): Promise { - if (this.variant === Variant.NoYield) { + if (this.variant === Variant.Jmi) { return new BN(1e12); } else if (this.variant === Variant.ScaledUi) { const yieldConfig: YieldConfig = ( @@ -1286,7 +1418,7 @@ export class ExtensionTest< } public async getNewExtIndex(newMIndex: BN): Promise { - if (this.variant === Variant.NoYield) { + if (this.variant === Variant.Jmi) { return new BN(1e12); } else if (this.variant === Variant.ScaledUi) { const yieldConfig: YieldConfig = ( @@ -1325,10 +1457,11 @@ export class ExtensionTest< if (expected.yieldConfig) { switch (this.variant) { - case Variant.NoYield: - expect(state.yieldConfig).toEqual({ - yieldVariant: { noYield: {} }, - }); + case Variant.Jmi: + this.expectJmiYieldConfig( + state.yieldConfig, + expected.yieldConfig + ); break; case Variant.ScaledUi: this.expectScaledUiYieldConfig( @@ -1348,6 +1481,10 @@ export class ExtensionTest< expect(state.mVaultBump).toEqual(expected.mVaultBump); if (expected.extMintAuthorityBump) expect(state.extMintAuthorityBump).toEqual(expected.extMintAuthorityBump); + if (expected.wrapAuthorities) + expect(state.wrapAuthorities).toEqual(expected.wrapAuthorities); + if (expected.replaceAuthorities) + expect(state.replaceAuthorities).toEqual(expected.replaceAuthorities); } private expectScaledUiYieldConfig( @@ -1399,6 +1536,19 @@ export class ExtensionTest< } } + private expectJmiYieldConfig( + actual: YieldConfig, + expected: YieldConfig + ) { + expect(actual.yieldVariant!).toEqual({ noYield: {} }); + + if (expected.totalAssets) { + expect(actual.totalAssets!.toString()).toEqual( + expected.totalAssets.toString() + ); + } + } + public async expectEarnManagerState( earnManagerAccount: PublicKey, expected: EarnManager @@ -1496,7 +1646,11 @@ export class ExtensionTest< } // Helper functions for executing MExt instructions - public async initializeExt(wrapAuthorities: PublicKey[], feeBps?: BN) { + public async initializeExt( + wrapAuthorities: PublicKey[], + feeBps?: BN, + replaceAuthorities: PublicKey[] = [] + ) { switch (this.variant) { case Variant.ScaledUi: if (!feeBps) { @@ -1504,7 +1658,7 @@ export class ExtensionTest< } // Send the transaction await this.ext.methods - .initialize(wrapAuthorities, feeBps) + .initialize(wrapAuthorities, replaceAuthorities, feeBps) .accounts({ admin: this.admin.publicKey, mMint: this.mMint.publicKey, @@ -1516,7 +1670,11 @@ export class ExtensionTest< break; case Variant.Crank: await this.ext.methods - .initialize(wrapAuthorities, this.earnAuthority.publicKey) + .initialize( + wrapAuthorities, + replaceAuthorities, + this.earnAuthority.publicKey + ) .accounts({ admin: this.admin.publicKey, mMint: this.mMint.publicKey, @@ -1526,10 +1684,10 @@ export class ExtensionTest< .signers([this.admin]) .rpc(); break; - case Variant.NoYield: + case Variant.Jmi: // Send the transaction await this.ext.methods - .initialize(wrapAuthorities) + .initialize(wrapAuthorities, replaceAuthorities) .accounts({ admin: this.admin.publicKey, mMint: this.mMint.publicKey, @@ -1621,6 +1779,7 @@ export class ExtensionTest< : this.ext.programId, fromMTokenAccount, toExtTokenAccount, + mTokenProgram: TOKEN_2022_PROGRAM_ID, extTokenProgram: this.extTokenProgram, }) .signers( @@ -1689,6 +1848,7 @@ export class ExtensionTest< : this.ext.programId, toMTokenAccount, fromExtTokenAccount, + mTokenProgram: TOKEN_2022_PROGRAM_ID, extTokenProgram: this.extTokenProgram, }) .signers( @@ -1699,10 +1859,272 @@ export class ExtensionTest< return { vaultMTokenAccount, toMTokenAccount, fromExtTokenAccount }; } + // ============================================ + // JMI-specific helper methods + // ============================================ + + public getAssetConfigAccount(assetMint: PublicKey): PublicKey { + const [assetConfig] = PublicKey.findProgramAddressSync( + [Buffer.from("asset_config"), assetMint.toBuffer()], + this.ext.programId + ); + return assetConfig; + } + + public async createAssetMint( + decimals: number = 6, + useToken2022: boolean = false + ): Promise { + const assetMint = new Keypair(); + await this.createMint( + assetMint, + this.admin.publicKey, + this.admin.publicKey, + useToken2022, + decimals + ); + return assetMint; + } + + public async mintAssetTokens( + assetMint: Keypair, + to: PublicKey, + amount: BN, + useToken2022: boolean = false + ): Promise { + const tokenAccount = await this.getATA( + assetMint.publicKey, + to, + useToken2022 + ); + + // Create mint-to instruction + const mintToIx = createMintToCheckedInstruction( + assetMint.publicKey, + tokenAccount, + this.admin.publicKey, + BigInt(amount.toString()), + 6, + [], + useToken2022 ? TOKEN_2022_PROGRAM_ID : TOKEN_PROGRAM_ID + ); + + const tx = new Transaction().add(mintToIx); + await this.provider.sendAndConfirm!(tx, [this.admin]); + + return tokenAccount; + } + + public async setAssetCap( + assetMint: PublicKey, + cap: BN, + admin?: Keypair, + useToken2022ForAsset: boolean = false + ): Promise { + if (this.variant !== Variant.Jmi) { + throw new Error("setAssetCap is only available for JMI variant"); + } + + const signer = admin ?? this.admin; + const assetTokenProgram = useToken2022ForAsset + ? TOKEN_2022_PROGRAM_ID + : TOKEN_PROGRAM_ID; + + await this.ext.methods + .setAssetCap(cap) + .accountsPartial({ + admin: signer.publicKey, + assetMint: assetMint, + assetTokenProgram: assetTokenProgram, + }) + .signers([signer]) + .rpc(); + } + + public async wrapAsset( + assetMint: PublicKey, + amount: BN, + tokenAuthority: Keypair, + wrapAuthority?: Keypair | null, + useToken2022ForAsset: boolean = false + ): Promise<{ + fromAssetTokenAccount: PublicKey; + toExtTokenAccount: PublicKey; + vaultAssetTokenAccount: PublicKey; + }> { + if (this.variant !== Variant.Jmi) { + throw new Error("wrapAsset is only available for JMI variant"); + } + + const mVault = this.getMVault(); + const assetTokenProgram = useToken2022ForAsset + ? TOKEN_2022_PROGRAM_ID + : TOKEN_PROGRAM_ID; + + const fromAssetTokenAccount = await this.getATA( + assetMint, + tokenAuthority.publicKey, + useToken2022ForAsset + ); + const toExtTokenAccount = await this.getATA( + this.extMint.publicKey, + tokenAuthority.publicKey, + this.useToken2022ForExt + ); + const vaultAssetTokenAccount = await this.getATA( + assetMint, + mVault, + useToken2022ForAsset + ); + + await this.ext.methods + .wrapAsset(amount) + .accountsPartial({ + tokenAuthority: tokenAuthority.publicKey, + replaceAuthority: wrapAuthority + ? wrapAuthority.publicKey + : this.ext.programId, + assetMint: assetMint, + assetConfig: this.getAssetConfigAccount(assetMint), + fromAssetTokenAccount, + toExtTokenAccount, + vaultAssetTokenAccount, + assetTokenProgram, + extTokenProgram: this.extTokenProgram, + }) + .signers( + wrapAuthority ? [tokenAuthority, wrapAuthority] : [tokenAuthority] + ) + .rpc(); + + return { fromAssetTokenAccount, toExtTokenAccount, vaultAssetTokenAccount }; + } + + public async replaceAssetWithM( + assetMint: PublicKey, + mPrincipal: BN, + tokenAuthority: Keypair, + replaceAuthority?: Keypair | null, + useToken2022ForAsset: boolean = false + ): Promise<{ + fromMTokenAccount: PublicKey; + toAssetTokenAccount: PublicKey; + vaultMTokenAccount: PublicKey; + vaultAssetTokenAccount: PublicKey; + }> { + if (this.variant !== Variant.Jmi) { + throw new Error("replaceAssetWithM is only available for JMI variant"); + } + + const mVault = this.getMVault(); + const assetTokenProgram = useToken2022ForAsset + ? TOKEN_2022_PROGRAM_ID + : TOKEN_PROGRAM_ID; + + const fromMTokenAccount = await this.getATA( + this.mMint.publicKey, + tokenAuthority.publicKey + ); + const toAssetTokenAccount = await this.getATA( + assetMint, + tokenAuthority.publicKey, + useToken2022ForAsset + ); + const vaultMTokenAccount = await this.getATA(this.mMint.publicKey, mVault); + const vaultAssetTokenAccount = await this.getATA( + assetMint, + mVault, + useToken2022ForAsset + ); + + await this.ext.methods + .replaceAssetWithM(mPrincipal) + .accountsPartial({ + tokenAuthority: tokenAuthority.publicKey, + replaceAuthority: replaceAuthority + ? replaceAuthority.publicKey + : this.ext.programId, // effectively a no-op if not replacing + mMint: this.mMint.publicKey, + assetMint: assetMint, + assetConfig: this.getAssetConfigAccount(assetMint), + fromMTokenAccount, + toAssetTokenAccount, + vaultMTokenAccount, + vaultAssetTokenAccount, + assetTokenProgram, + mTokenProgram: TOKEN_2022_PROGRAM_ID, + }) + .signers( + replaceAuthority + ? [tokenAuthority, replaceAuthority] + : [tokenAuthority] + ) + .rpc(); + + return { + fromMTokenAccount, + toAssetTokenAccount, + vaultMTokenAccount, + vaultAssetTokenAccount, + }; + } + + public async setupJmiAsset( + cap: BN, + decimals: number = 6, + useToken2022ForAsset: boolean = false + ): Promise<{ assetMint: Keypair; vaultAssetTokenAccount: PublicKey }> { + if (this.variant !== Variant.Jmi) { + throw new Error("setupJmiAsset is only available for JMI variant"); + } + + // Create asset mint + const assetMint = await this.createAssetMint(decimals, useToken2022ForAsset); + + // Set asset cap (this also creates the vault ATA) + await this.setAssetCap(assetMint.publicKey, cap, undefined, useToken2022ForAsset); + + // Get the vault ATA + const vaultAssetTokenAccount = await this.getATA( + assetMint.publicKey, + this.getMVault(), + useToken2022ForAsset + ); + + return { assetMint, vaultAssetTokenAccount }; + } + + public async expectAssetConfigState( + assetMint: PublicKey, + expected: AssetConfig + ): Promise { + if (this.variant !== Variant.Jmi) { + throw new Error( + "expectAssetConfigState is only available for JMI variant" + ); + } + + const assetConfigAccount = this.getAssetConfigAccount(assetMint); + const assetConfig = await this.ext.account.assetConfig.fetch( + assetConfigAccount + ); + + if (expected.cap !== undefined) { + expect(assetConfig.cap.toString()).toBe(expected.cap.toString()); + } + if (expected.bump !== undefined) { + expect(assetConfig.bump).toBe(expected.bump); + } + } + + // ============================================ + // End JMI-specific helper methods + // ============================================ + public async sync(): Promise { switch (this.variant) { - case Variant.NoYield: - throw new Error("sync is not supported for No Yield variant"); + case Variant.Jmi: + throw new Error("sync is not supported for JMI variant"); break; case Variant.ScaledUi: // Sync is open to any address @@ -2022,7 +2444,7 @@ export class ExtensionSwapTest extends ExtensionTestBase { super(addresses); // Load and add extension programs - const NO_YIELD_IDL = require("../../target/idl/no_yield.json"); + const JMI_IDL = require("../../target/idl/jmi.json"); const SCALED_UI_IDL = require("../../target/idl/scaled_ui.json"); // Add the test extension programs to SVM @@ -2039,9 +2461,9 @@ export class ExtensionSwapTest extends ExtensionTestBase { "tests/programs/ext_c.so" ); - // Create program instances + // Create program instances (using JMI IDL for no-yield variants since JMI is a superset) this.extensionPrograms.extA = new Program( - { ...NO_YIELD_IDL, address: ExtensionSwapTest.EXT_PROGRAM_IDS.extA }, + { ...JMI_IDL, address: ExtensionSwapTest.EXT_PROGRAM_IDS.extA }, this.provider ); this.extensionPrograms.extB = new Program( @@ -2049,7 +2471,7 @@ export class ExtensionSwapTest extends ExtensionTestBase { this.provider ); this.extensionPrograms.extC = new Program( - { ...NO_YIELD_IDL, address: ExtensionSwapTest.EXT_PROGRAM_IDS.extC }, + { ...JMI_IDL, address: ExtensionSwapTest.EXT_PROGRAM_IDS.extC }, this.provider ); @@ -2146,9 +2568,9 @@ export class ExtensionSwapTest extends ExtensionTestBase { } private async initializeExtensionPrograms() { - // ext_a: no-yield variant - initialize with just wrap authorities + // ext_a: no-yield variant - initialize with wrap authorities and replace authorities await this.extensionPrograms.extA.methods - .initialize([this.admin.publicKey]) + .initialize([this.admin.publicKey], [this.admin.publicKey]) .accounts({ admin: this.admin.publicKey, mMint: this.mMint.publicKey, @@ -2160,7 +2582,7 @@ export class ExtensionSwapTest extends ExtensionTestBase { // ext_b: scaled-ui variant - initialize with wrap authorities and fee_bps await this.extensionPrograms.extB.methods - .initialize([this.admin.publicKey], new BN(500)) // 5% fee + .initialize([this.admin.publicKey], [], new BN(500)) // 5% fee .accounts({ admin: this.admin.publicKey, mMint: this.mMint.publicKey, @@ -2170,9 +2592,9 @@ export class ExtensionSwapTest extends ExtensionTestBase { .signers([this.admin]) .rpc(); - // ext_c: no-yield variant with extra accounts - initialize with just wrap authorities + // ext_c: no-yield variant with extra accounts - initialize with wrap authorities and replace authorities await this.extensionPrograms.extC.methods - .initialize([this.admin.publicKey]) + .initialize([this.admin.publicKey], [this.admin.publicKey]) .accounts({ admin: this.admin.publicKey, mMint: this.mMint.publicKey, @@ -2235,6 +2657,22 @@ export class ExtensionSwapTest extends ExtensionTestBase { .rpc(); } + public async addReplaceAuthorityToExtension( + extensionKey: string, + authority: PublicKey + ) { + const program = this.extensionPrograms[extensionKey]; + if (!program) throw new Error(`Extension ${extensionKey} not found`); + + await program.methods + .addReplaceAuthority(authority) + .accounts({ + admin: this.admin.publicKey, + }) + .signers([this.admin]) + .rpc(); + } + public getSwapGlobalAccount(): PublicKey { return PublicKey.findProgramAddressSync( [Buffer.from("global")], @@ -2275,4 +2713,247 @@ export class ExtensionSwapTest extends ExtensionTestBase { await this.addMEarner(vault); } } + + // ============================================ + // JMI-specific helper methods for ExtensionSwapTest + // ============================================ + + public getReplaceAuthorityPda(): PublicKey { + return PublicKey.findProgramAddressSync( + [Buffer.from("replace_authority")], + this.swapProgram.programId + )[0]; + } + + public getExtGlobalAccountFor(extKey: string): PublicKey { + const program = this.extensionPrograms[extKey]; + if (!program) throw new Error(`Extension ${extKey} not found`); + return PublicKey.findProgramAddressSync( + [Buffer.from("global")], + program.programId + )[0]; + } + + public getAssetConfigAccount(extKey: string, assetMint: PublicKey): PublicKey { + const program = this.extensionPrograms[extKey]; + if (!program) throw new Error(`Extension ${extKey} not found`); + return PublicKey.findProgramAddressSync( + [Buffer.from("asset_config"), assetMint.toBuffer()], + program.programId + )[0]; + } + + public async createAssetMint( + decimals: number = 6, + useToken2022: boolean = false + ): Promise { + const assetMint = new Keypair(); + await this.createMint( + assetMint, + this.admin.publicKey, + this.admin.publicKey, + useToken2022, + decimals + ); + return assetMint; + } + + public async mintAssetTokensTo( + assetMint: Keypair, + to: PublicKey, + amount: BN, + useToken2022: boolean = false + ): Promise { + const tokenAccount = await this.getATA( + assetMint.publicKey, + to, + useToken2022 + ); + + const mintToIx = createMintToCheckedInstruction( + assetMint.publicKey, + tokenAccount, + this.admin.publicKey, + BigInt(amount.toString()), + 6, + [], + useToken2022 ? TOKEN_2022_PROGRAM_ID : TOKEN_PROGRAM_ID + ); + + const tx = new Transaction().add(mintToIx); + await this.provider.sendAndConfirm!(tx, [this.admin]); + + return tokenAccount; + } + + public async setAssetCapOnExtension( + extKey: string, + assetMint: PublicKey, + cap: BN, + useToken2022ForAsset: boolean = false + ): Promise { + const program = this.extensionPrograms[extKey]; + if (!program) throw new Error(`Extension ${extKey} not found`); + + const assetTokenProgram = useToken2022ForAsset + ? TOKEN_2022_PROGRAM_ID + : TOKEN_PROGRAM_ID; + + await program.methods + .setAssetCap(cap) + .accountsPartial({ + admin: this.admin.publicKey, + assetMint: assetMint, + assetTokenProgram: assetTokenProgram, + }) + .signers([this.admin]) + .rpc(); + } + + public async wrapAssetViaSwap( + extKey: string, + assetMint: PublicKey, + amount: BN, + signer: Keypair, + replaceAuthority?: Keypair, + useToken2022ForAsset: boolean = false + ): Promise { + const program = this.extensionPrograms[extKey]; + if (!program) throw new Error(`Extension ${extKey} not found`); + + const extMint = this.extensionMints[extKey.replace("ext", "mint")]; + if (!extMint) throw new Error(`Extension mint for ${extKey} not found`); + + const mVault = this.getMVaultForExtension(program.programId); + const assetTokenProgram = useToken2022ForAsset + ? TOKEN_2022_PROGRAM_ID + : TOKEN_PROGRAM_ID; + + const userAssetAccount = await this.getATA( + assetMint, + signer.publicKey, + useToken2022ForAsset + ); + const userExtAccount = await this.getATA( + extMint.publicKey, + signer.publicKey, + true + ); + const vaultAssetAccount = await this.getATA( + assetMint, + mVault, + useToken2022ForAsset + ); + + const signers = replaceAuthority + ? [signer, replaceAuthority] + : [signer]; + + return this.swapProgram.methods + .wrapAsset(amount) + .accountsPartial({ + signer: signer.publicKey, + replaceAuthority: replaceAuthority + ? replaceAuthority.publicKey + : null, + fallbackReplaceAuthority: this.getReplaceAuthorityPda(), + swapGlobal: this.getSwapGlobalAccount(), + toGlobal: this.getExtGlobalAccountFor(extKey), + toMint: extMint.publicKey, + assetMint: assetMint, + assetConfig: this.getAssetConfigAccount(extKey, assetMint), + assetTokenAccount: userAssetAccount, + toTokenAccount: userExtAccount, + toVaultAuth: mVault, + toMintAuthority: PublicKey.findProgramAddressSync( + [Buffer.from("mint_authority")], + program.programId + )[0], + toAssetVault: vaultAssetAccount, + toTokenProgram: TOKEN_2022_PROGRAM_ID, + assetTokenProgram: assetTokenProgram, + toExtProgram: program.programId, + }) + .signers(signers) + .rpc(); + } + + public async replaceAssetWithMViaSwap( + fromExtKey: string, + jmiExtKey: string, + assetMint: PublicKey, + extPrincipal: BN, + signer: Keypair, + replaceAuthority?: Keypair, + useToken2022ForAsset: boolean = false + ): Promise { + const fromProgram = this.extensionPrograms[fromExtKey]; + const jmiProgram = this.extensionPrograms[jmiExtKey]; + if (!fromProgram) throw new Error(`Extension ${fromExtKey} not found`); + if (!jmiProgram) throw new Error(`Extension ${jmiExtKey} not found`); + + const fromMint = this.extensionMints[fromExtKey.replace("ext", "mint")]; + if (!fromMint) throw new Error(`Extension mint for ${fromExtKey} not found`); + + const fromMVault = this.getMVaultForExtension(fromProgram.programId); + const jmiMVault = this.getMVaultForExtension(jmiProgram.programId); + const assetTokenProgram = useToken2022ForAsset + ? TOKEN_2022_PROGRAM_ID + : TOKEN_PROGRAM_ID; + + const userFromAccount = await this.getATA( + fromMint.publicKey, + signer.publicKey, + true + ); + const userAssetAccount = await this.getATA( + assetMint, + signer.publicKey, + useToken2022ForAsset + ); + const swapMAccount = await this.getATA( + this.mMint.publicKey, + this.getSwapGlobalAccount(), + true + ); + + const signers = replaceAuthority + ? [signer, replaceAuthority] + : [signer]; + + return this.swapProgram.methods + .replaceAssetWithM(extPrincipal) + .accountsPartial({ + signer: signer.publicKey, + replaceAuthority: replaceAuthority + ? replaceAuthority.publicKey + : null, + swapGlobal: this.getSwapGlobalAccount(), + fromGlobal: this.getExtGlobalAccountFor(fromExtKey), + jmiGlobal: this.getExtGlobalAccountFor(jmiExtKey), + fromMint: fromMint.publicKey, + mMint: this.mMint.publicKey, + assetMint: assetMint, + assetConfig: this.getAssetConfigAccount(jmiExtKey, assetMint), + fromTokenAccount: userFromAccount, + toAssetTokenAccount: userAssetAccount, + swapMAccount: swapMAccount, + fromMVaultAuth: fromMVault, + fromMintAuthority: PublicKey.findProgramAddressSync( + [Buffer.from("mint_authority")], + fromProgram.programId + )[0], + fromMVault: await this.getATA(this.mMint.publicKey, fromMVault, true), + jmiMVaultAuth: jmiMVault, + jmiMVault: await this.getATA(this.mMint.publicKey, jmiMVault, true), + jmiAssetVault: await this.getATA(assetMint, jmiMVault, useToken2022ForAsset), + fromTokenProgram: TOKEN_2022_PROGRAM_ID, + assetTokenProgram: assetTokenProgram, + mTokenProgram: TOKEN_2022_PROGRAM_ID, + fromExtProgram: fromProgram.programId, + jmiExtProgram: jmiProgram.programId, + }) + .signers(signers) + .rpc(); + } } diff --git a/tests/unit/m_ext.test.ts b/tests/unit/m_ext.test.ts index 09d753f..5b73424 100644 --- a/tests/unit/m_ext.test.ts +++ b/tests/unit/m_ext.test.ts @@ -24,11 +24,11 @@ const initialIndex = new BN(1_100_000_000_000); // 1.1 with 12 decimals const ONE = new BN(1_000_000_000_000); // 1.0 with 12 decimals const VARIANTS = [ - [Variant.NoYield, TOKEN_2022_PROGRAM_ID], - [Variant.NoYield, TOKEN_PROGRAM_ID], [Variant.ScaledUi, TOKEN_2022_PROGRAM_ID], [Variant.Crank, TOKEN_PROGRAM_ID], [Variant.Crank, TOKEN_2022_PROGRAM_ID], + [Variant.Jmi, TOKEN_2022_PROGRAM_ID], + [Variant.Jmi, TOKEN_PROGRAM_ID], ]; // Implement test cases for all variants @@ -90,11 +90,11 @@ for (const [variant, tokenProgramId] of VARIANTS) { // Attempt to send the transaction await $.expectSystemError( - (variant === Variant.NoYield - ? $.ext.methods.initialize([]) + (variant === Variant.Jmi + ? $.ext.methods.initialize([], []) : variant === Variant.ScaledUi - ? $.ext.methods.initialize([], new BN(0)) - : $.ext.methods.initialize([], $.earnAuthority.publicKey) + ? $.ext.methods.initialize([], [], new BN(0)) + : $.ext.methods.initialize([], [], $.earnAuthority.publicKey) ) .accountsPartial({ admin: $.nonAdmin.publicKey, @@ -122,11 +122,11 @@ for (const [variant, tokenProgramId] of VARIANTS) { // Attempt to send the transaction await $.expectAnchorError( - (variant === Variant.NoYield - ? $.ext.methods.initialize([]) + (variant === Variant.Jmi + ? $.ext.methods.initialize([], []) : variant === Variant.ScaledUi - ? $.ext.methods.initialize([], new BN(0)) - : $.ext.methods.initialize([], $.earnAuthority.publicKey) + ? $.ext.methods.initialize([], [], new BN(0)) + : $.ext.methods.initialize([], [], $.earnAuthority.publicKey) ) .accounts({ admin: $.nonAdmin.publicKey, @@ -155,11 +155,11 @@ for (const [variant, tokenProgramId] of VARIANTS) { // Attempt to send the transaction await $.expectAnchorError( - (variant === Variant.NoYield - ? $.ext.methods.initialize([]) + (variant === Variant.Jmi + ? $.ext.methods.initialize([], []) : variant === Variant.ScaledUi - ? $.ext.methods.initialize([], new BN(0)) - : $.ext.methods.initialize([], $.earnAuthority.publicKey) + ? $.ext.methods.initialize([], [], new BN(0)) + : $.ext.methods.initialize([], [], $.earnAuthority.publicKey) ) .accounts({ admin: $.nonAdmin.publicKey, @@ -183,11 +183,11 @@ for (const [variant, tokenProgramId] of VARIANTS) { // Attempt to send transaction // Expect error (could be one of several "SeedsConstraint", "AccountOwnedByWrongProgram", "AccountNotInitialized") await $.expectSystemError( - (variant === Variant.NoYield - ? $.ext.methods.initialize([]) + (variant === Variant.Jmi + ? $.ext.methods.initialize([], []) : variant === Variant.ScaledUi - ? $.ext.methods.initialize([], new BN(0)) - : $.ext.methods.initialize([], $.earnAuthority.publicKey) + ? $.ext.methods.initialize([], [], new BN(0)) + : $.ext.methods.initialize([], [], $.earnAuthority.publicKey) ) .accountsPartial({ admin: $.nonAdmin.publicKey, @@ -211,11 +211,11 @@ for (const [variant, tokenProgramId] of VARIANTS) { // Attempt to send transaction // Expect error (could be one of several "SeedsConstraint", "AccountOwnedByWrongProgram", "AccountNotInitialized") await $.expectSystemError( - (variant === Variant.NoYield - ? $.ext.methods.initialize([]) + (variant === Variant.Jmi + ? $.ext.methods.initialize([], []) : variant === Variant.ScaledUi - ? $.ext.methods.initialize([], new BN(0)) - : $.ext.methods.initialize([], $.earnAuthority.publicKey) + ? $.ext.methods.initialize([], [], new BN(0)) + : $.ext.methods.initialize([], [], $.earnAuthority.publicKey) ) .accountsPartial({ admin: $.nonAdmin.publicKey, @@ -244,11 +244,11 @@ for (const [variant, tokenProgramId] of VARIANTS) { // Attempt to send the transaction await $.expectAnchorError( - (variant === Variant.NoYield - ? $.ext.methods.initialize([]) + (variant === Variant.Jmi + ? $.ext.methods.initialize([], []) : variant === Variant.ScaledUi - ? $.ext.methods.initialize([], new BN(0)) - : $.ext.methods.initialize([], $.earnAuthority.publicKey) + ? $.ext.methods.initialize([], [], new BN(0)) + : $.ext.methods.initialize([], [], $.earnAuthority.publicKey) ) .accounts({ admin: $.nonAdmin.publicKey, @@ -271,12 +271,13 @@ for (const [variant, tokenProgramId] of VARIANTS) { // Attempt to send transaction await $.expectAnchorError( - (variant === Variant.NoYield - ? $.ext.methods.initialize(wrapAuthorities) + (variant === Variant.Jmi + ? $.ext.methods.initialize(wrapAuthorities, []) : variant === Variant.ScaledUi - ? $.ext.methods.initialize(wrapAuthorities, new BN(0)) + ? $.ext.methods.initialize(wrapAuthorities, [], new BN(0)) : $.ext.methods.initialize( wrapAuthorities, + [], $.earnAuthority.publicKey ) ) @@ -302,7 +303,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { // [X] the bumps are set correctly // [X] the wrap authorities are set correctly - if (variant === Variant.NoYield) { + if (variant === Variant.Jmi) { // given accounts and params are correct // it creates the global account // it sets the admin to the signer @@ -338,7 +339,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { // Send the transaction await $.ext.methods - .initialize(wrapAuthorities) + .initialize(wrapAuthorities, []) .accounts({ admin: $.admin.publicKey, mMint: $.mMint.publicKey, @@ -359,12 +360,13 @@ for (const [variant, tokenProgramId] of VARIANTS) { extMintAuthorityBump, yieldConfig: { yieldVariant: { noYield: {} }, + totalAssets: new BN(0), }, wrapAuthorities, }); // Confirm the size of the global account based on the number of wrap authorities - const expectedSize = 176 + 1 + wrapAuthorities.length * 32; // 176 bytes base size + 1 for yield config discriminator + 4 bytes for vector length + 32 bytes per wrap authority + const expectedSize = 176 + 9 + 4 + wrapAuthorities.length * 32; // 176 bytes base size + 9 for yield config + 4 bytes for wrap_authorities vec + 4 bytes for replace_authorities vec + 32 bytes per wrap authority const extGlobalSize = await $.provider.connection .getAccountInfo(globalAccount) .then((info) => info?.data.length || 0); @@ -405,7 +407,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { // Attempt to send the transaction await $.expectAnchorError( $.ext.methods - .initialize([], new BN(0)) + .initialize([], [], new BN(0)) .accounts({ admin: $.nonAdmin.publicKey, mMint: $.mMint.publicKey, @@ -433,7 +435,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { // Attempt to send the transaction await $.expectAnchorError( $.ext.methods - .initialize([], new BN(0)) + .initialize([], [], new BN(0)) .accounts({ admin: $.nonAdmin.publicKey, mMint: $.mMint.publicKey, @@ -484,7 +486,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { // Send the transaction await $.ext.methods - .initialize(wrapAuthorities, feeBps) + .initialize(wrapAuthorities, [], feeBps) .accounts({ admin: $.admin.publicKey, mMint: $.mMint.publicKey, @@ -513,7 +515,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { }); // Check the size of the global account based on the number of wrap authorities - const expectedSize = 176 + 25 + wrapAuthorities.length * 32; // 176 bytes base size + 25 yield config size + 32 bytes per wrap authority + const expectedSize = 176 + 25 + 4 + wrapAuthorities.length * 32; // 176 bytes base size + 25 yield config size + 4 bytes for replace_authorities vec + 32 bytes per wrap authority const extGlobalSize = await $.provider.connection .getAccountInfo(globalAccount) .then((info) => info?.data.length || 0); @@ -540,7 +542,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { wrapAuthorities = [$.admin.publicKey, $.wrapAuthority.publicKey]; const feeBps = - variant === Variant.NoYield ? new BN(0) : new BN(randomInt(10000)); + variant === Variant.Jmi ? new BN(0) : new BN(randomInt(10000)); // Initialize the extension program await $.initializeExt(wrapAuthorities, feeBps); @@ -723,7 +725,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { wrapAuthorities = [$.admin.publicKey, $.wrapAuthority.publicKey]; const feeBps = - variant === Variant.NoYield ? new BN(0) : new BN(randomInt(10000)); + variant === Variant.Jmi ? new BN(0) : new BN(randomInt(10000)); // Initialize the extension program await $.initializeExt(wrapAuthorities, feeBps); }); @@ -811,7 +813,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { wrapAuthorities = [$.admin.publicKey, $.wrapAuthority.publicKey]; const feeBps = - variant === Variant.NoYield ? new BN(0) : new BN(randomInt(10000)); + variant === Variant.Jmi ? new BN(0) : new BN(randomInt(10000)); // Initialize the extension program await $.initializeExt(wrapAuthorities, feeBps); }); @@ -899,15 +901,15 @@ for (const [variant, tokenProgramId] of VARIANTS) { // [X] it reverts with a NotAuthorized error // [X] given the admin signs the transaction // [X] given the m vault is not the m vault PDA - // [X] it reverts with a ConstraintSeeds error + // [X] it reverts with a AccountNotInitialized error // [X] given the m vault token account is not the m vault PDA's ATA // [X] it reverts with a ConstraintAssociated error // [X] given the ext mint does not match the one on the global account // [X] it reverts with an InvalidMint error // [X] given the ext mint authority is not the ext mint authority PDA - // [X] it reverts with a ConstraintSeeds error + // [X] it reverts with a AccountNotInitialized error // [X] given the m earn global account does not match the derived PDA - // [X] it reverts with a ConstraintSeeds error + // [X] it reverts with a AccountNotInitialized error // [X] given the recipient token account is not a token account for the m mint // [X] it reverts with a ConstraintTokenMint error // [X] given the vault m token account is frozen (i.e. extension is not approved as an earner) @@ -1376,7 +1378,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { // [X] it transfers the excess collateral to the recipient token account // [X] given the m vault does not have excess collateral // [X] it completes but doesn't transfer any tokens - if (variant === Variant.NoYield) { + if (variant === Variant.Jmi) { // given all accounts are correct // given the m vault has excess collateral // it transfers the excess collateral to the recipient token account @@ -2167,6 +2169,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { fromMTokenAccount, toExtTokenAccount, vaultMTokenAccount, + mTokenProgram: TOKEN_2022_PROGRAM_ID, extTokenProgram: $.extTokenProgram, }) .signers([$.wrapAuthority]) @@ -2204,6 +2207,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { extMint: wrongMint.publicKey, fromMTokenAccount, toExtTokenAccount, + mTokenProgram: TOKEN_2022_PROGRAM_ID, extTokenProgram: $.extTokenProgram, }) .signers([$.wrapAuthority]) @@ -2231,6 +2235,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { wrapAuthority: $.ext.programId, fromMTokenAccount, toExtTokenAccount, + mTokenProgram: TOKEN_2022_PROGRAM_ID, extTokenProgram: $.extTokenProgram, }) .signers([$.wrapAuthority]) @@ -2257,6 +2262,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { fromMTokenAccount, toExtTokenAccount, vaultMTokenAccount, + mTokenProgram: TOKEN_2022_PROGRAM_ID, extTokenProgram: $.extTokenProgram, }) .signers([$.wrapAuthority]) @@ -2278,6 +2284,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { wrapAuthority: $.ext.programId, fromMTokenAccount: toExtTokenAccount, toExtTokenAccount, + mTokenProgram: TOKEN_2022_PROGRAM_ID, extTokenProgram: $.extTokenProgram, }) .signers([$.wrapAuthority]) @@ -2299,6 +2306,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { wrapAuthority: $.ext.programId, toExtTokenAccount: fromMTokenAccount, fromMTokenAccount, + mTokenProgram: TOKEN_2022_PROGRAM_ID, extTokenProgram: $.extTokenProgram, }) .signers([$.wrapAuthority]) @@ -2331,6 +2339,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { wrapAuthority: $.ext.programId, fromMTokenAccount, toExtTokenAccount, + mTokenProgram: TOKEN_2022_PROGRAM_ID, extTokenProgram: $.extTokenProgram, }) .signers([$.nonWrapAuthority]) @@ -2359,6 +2368,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { fromMTokenAccount, toExtTokenAccount, extTokenProgram: $.extTokenProgram, + mTokenProgram: TOKEN_2022_PROGRAM_ID, }) .signers([$.wrapAuthority]) .rpc() @@ -2375,6 +2385,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { fromMTokenAccount, toExtTokenAccount, extTokenProgram: $.extTokenProgram, + mTokenProgram: TOKEN_2022_PROGRAM_ID, }) .signers([$.wrapAuthority]) .rpc(), @@ -2446,6 +2457,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { wrapAuthority: $.ext.programId, fromMTokenAccount, toExtTokenAccount, + mTokenProgram: TOKEN_2022_PROGRAM_ID, extTokenProgram: $.extTokenProgram, }) .signers([$.wrapAuthority]) @@ -2523,6 +2535,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { wrapAuthority: $.ext.programId, fromMTokenAccount, toExtTokenAccount, + mTokenProgram: TOKEN_2022_PROGRAM_ID, extTokenProgram: $.extTokenProgram, }) .signers([$.wrapAuthority]) @@ -2607,6 +2620,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { wrapAuthority: $.ext.programId, fromMTokenAccount, toExtTokenAccount, + mTokenProgram: TOKEN_2022_PROGRAM_ID, extTokenProgram: $.extTokenProgram, }) .signers([$.wrapAuthority]) @@ -2688,6 +2702,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { wrapAuthority: $.nonWrapAuthority.publicKey, fromMTokenAccount, toExtTokenAccount, + mTokenProgram: TOKEN_2022_PROGRAM_ID, extTokenProgram: $.extTokenProgram, }) .signers([$.nonWrapAuthority]) @@ -2716,6 +2731,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { fromMTokenAccount, toExtTokenAccount, extTokenProgram: $.extTokenProgram, + mTokenProgram: TOKEN_2022_PROGRAM_ID }) .signers([$.nonWrapAuthority, $.wrapAuthority]) .rpc() @@ -2783,6 +2799,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { wrapAuthority: $.wrapAuthority.publicKey, fromMTokenAccount, toExtTokenAccount, + mTokenProgram: TOKEN_2022_PROGRAM_ID, extTokenProgram: $.extTokenProgram, }) .signers([$.nonAdmin, $.wrapAuthority]) @@ -2871,6 +2888,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { wrapAuthority: $.wrapAuthority.publicKey, fromMTokenAccount, toExtTokenAccount, + mTokenProgram: TOKEN_2022_PROGRAM_ID, extTokenProgram: $.extTokenProgram, }) .signers([$.nonWrapAuthority, $.wrapAuthority]) @@ -3021,6 +3039,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { fromMTokenAccount, toExtTokenAccount, extTokenProgram: $.extTokenProgram, + mTokenProgram: TOKEN_2022_PROGRAM_ID }) .signers([$.wrapAuthority]) .rpc(); @@ -3104,6 +3123,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { wrapAuthority: $.ext.programId, fromMTokenAccount, toExtTokenAccount, + mTokenProgram: TOKEN_2022_PROGRAM_ID, extTokenProgram: $.extTokenProgram, }) .signers([$.wrapAuthority]) @@ -3190,6 +3210,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { fromMTokenAccount, toExtTokenAccount, extTokenProgram: $.extTokenProgram, + mTokenProgram: TOKEN_2022_PROGRAM_ID, }) .signers([$.wrapAuthority]) .rpc(); @@ -3283,6 +3304,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { wrapAuthority: $.ext.programId, fromMTokenAccount, toExtTokenAccount, + mTokenProgram: TOKEN_2022_PROGRAM_ID, extTokenProgram: $.extTokenProgram, }) .signers([$.wrapAuthority]) @@ -3381,6 +3403,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { fromMTokenAccount, toExtTokenAccount, extTokenProgram: $.extTokenProgram, + mTokenProgram: TOKEN_2022_PROGRAM_ID, }) .signers([$.wrapAuthority]) .rpc(); @@ -3477,6 +3500,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { wrapAuthority: $.ext.programId, fromMTokenAccount, toExtTokenAccount, + mTokenProgram: TOKEN_2022_PROGRAM_ID, extTokenProgram: $.extTokenProgram, }) .signers([$.wrapAuthority]) @@ -3622,6 +3646,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { fromExtTokenAccount, toMTokenAccount, vaultMTokenAccount, + mTokenProgram: TOKEN_2022_PROGRAM_ID, extTokenProgram: $.extTokenProgram, }) .signers([$.wrapAuthority]) @@ -3660,6 +3685,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { extMint: wrongMint.publicKey, fromExtTokenAccount, toMTokenAccount, + mTokenProgram: TOKEN_2022_PROGRAM_ID, extTokenProgram: $.extTokenProgram, }) .signers([$.wrapAuthority]) @@ -3689,6 +3715,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { fromExtTokenAccount, toMTokenAccount, extTokenProgram: $.extTokenProgram, + mTokenProgram: TOKEN_2022_PROGRAM_ID, }) .signers([$.wrapAuthority]) .rpc() @@ -3719,6 +3746,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { fromExtTokenAccount, toMTokenAccount, vaultMTokenAccount, + mTokenProgram: TOKEN_2022_PROGRAM_ID, extTokenProgram: $.extTokenProgram, }) .signers([$.wrapAuthority]) @@ -3740,6 +3768,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { unwrapAuthority: $.ext.programId, toMTokenAccount: fromExtTokenAccount, fromExtTokenAccount, + mTokenProgram: TOKEN_2022_PROGRAM_ID, extTokenProgram: $.extTokenProgram, }) .signers([$.wrapAuthority]) @@ -3762,6 +3791,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { fromExtTokenAccount: toMTokenAccount, toMTokenAccount, extTokenProgram: $.extTokenProgram, + mTokenProgram: TOKEN_2022_PROGRAM_ID, }) .signers([$.wrapAuthority]) .rpc(), @@ -3791,6 +3821,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { unwrapAuthority: $.ext.programId, fromExtTokenAccount, toMTokenAccount, + mTokenProgram: TOKEN_2022_PROGRAM_ID, extTokenProgram: $.extTokenProgram, }) .signers([$.nonWrapAuthority]) @@ -3826,6 +3857,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { fromExtTokenAccount, toMTokenAccount, extTokenProgram: $.extTokenProgram, + mTokenProgram: TOKEN_2022_PROGRAM_ID, }) .signers([$.wrapAuthority]) .rpc() @@ -3842,6 +3874,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { unwrapAuthority: $.ext.programId, fromExtTokenAccount, toMTokenAccount, + mTokenProgram: TOKEN_2022_PROGRAM_ID, extTokenProgram: $.extTokenProgram, }) .signers([$.wrapAuthority]) @@ -3909,6 +3942,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { fromExtTokenAccount, toMTokenAccount, extTokenProgram: $.extTokenProgram, + mTokenProgram: TOKEN_2022_PROGRAM_ID, }) .signers([$.wrapAuthority]) .rpc(); @@ -3987,6 +4021,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { unwrapAuthority: $.ext.programId, fromExtTokenAccount, toMTokenAccount, + mTokenProgram: TOKEN_2022_PROGRAM_ID, extTokenProgram: $.extTokenProgram, }) .signers([$.wrapAuthority]) @@ -4071,6 +4106,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { unwrapAuthority: $.ext.programId, fromExtTokenAccount, toMTokenAccount, + mTokenProgram: TOKEN_2022_PROGRAM_ID, extTokenProgram: $.extTokenProgram, }) .signers([$.wrapAuthority]) @@ -4131,6 +4167,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { fromExtTokenAccount, toMTokenAccount, extTokenProgram: $.extTokenProgram, + mTokenProgram: TOKEN_2022_PROGRAM_ID, }) .signers([$.nonWrapAuthority]) .rpc() @@ -4162,6 +4199,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { fromExtTokenAccount, toMTokenAccount, extTokenProgram: $.extTokenProgram, + mTokenProgram: TOKEN_2022_PROGRAM_ID, }) .signers([$.nonWrapAuthority, $.nonAdmin]) .rpc(), @@ -4208,6 +4246,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { fromExtTokenAccount, toMTokenAccount, extTokenProgram: $.extTokenProgram, + mTokenProgram: TOKEN_2022_PROGRAM_ID, }) .signers([$.nonWrapAuthority, $.wrapAuthority]) .rpc() @@ -4278,6 +4317,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { fromExtTokenAccount, toMTokenAccount, extTokenProgram: $.extTokenProgram, + mTokenProgram: TOKEN_2022_PROGRAM_ID, }) .signers([$.nonAdmin, $.wrapAuthority]) .rpc(); @@ -4372,6 +4412,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { fromExtTokenAccount, toMTokenAccount, extTokenProgram: $.extTokenProgram, + mTokenProgram: TOKEN_2022_PROGRAM_ID, }) .signers([$.nonWrapAuthority, $.wrapAuthority]) .rpc(); @@ -4474,6 +4515,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { fromExtTokenAccount, toMTokenAccount, extTokenProgram: $.extTokenProgram, + mTokenProgram: TOKEN_2022_PROGRAM_ID, }) .signers([$.wrapAuthority]) .rpc(); @@ -4574,6 +4616,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { fromExtTokenAccount, toMTokenAccount, extTokenProgram: $.extTokenProgram, + mTokenProgram: TOKEN_2022_PROGRAM_ID, }) .signers([$.wrapAuthority]) .rpc(); @@ -4673,6 +4716,7 @@ for (const [variant, tokenProgramId] of VARIANTS) { fromExtTokenAccount, toMTokenAccount, extTokenProgram: $.extTokenProgram, + mTokenProgram: TOKEN_2022_PROGRAM_ID, }) .signers([$.wrapAuthority]) .rpc(); @@ -7733,5 +7777,1108 @@ for (const [variant, tokenProgramId] of VARIANTS) { }); }); } - }); -} + + // JMI-specific tests (JMI variant only) + if (variant === Variant.Jmi) { + describe("JMI instruction tests", () => { + let wrapAuthorities: PublicKey[]; + let replaceAuthorities: PublicKey[]; + + beforeEach(async () => { + wrapAuthorities = [$.admin.publicKey, $.wrapAuthority.publicKey]; + replaceAuthorities = [$.admin.publicKey, $.wrapAuthority.publicKey]; + await $.initializeExt(wrapAuthorities, undefined, replaceAuthorities); + }); + + describe("add_replace_authority unit tests", () => { + // test cases + // [X] given the admin does not sign the transaction + // [X] it reverts with a NotAuthorized error + // [X] given the admin signs the transaction + // [X] given the new replace authority is already in the list + // [X] it reverts with a InvalidParam error + // [X] given the new replace authority is not in the list + // [X] it adds the new replace authority to the list + // [X] it resizes the ext global account to accommodate the new replace authority + + // given the admin does not sign the transaction + // it reverts with a NotAuthorized error + test("admin does not sign - reverts", async () => { + await $.expectAnchorError( + $.ext.methods + .addReplaceAuthority($.nonWrapAuthority.publicKey) + .accounts({ + admin: $.nonAdmin.publicKey, + }) + .signers([$.nonAdmin]) + .rpc(), + "NotAuthorized" + ); + }); + + // given the admin signs the transaction + // given the new replace authority is already in the list + // it reverts with a InvalidParam error + test("new replace authority already in the list - reverts", async () => { + await $.expectAnchorError( + $.ext.methods + .addReplaceAuthority($.wrapAuthority.publicKey) + .accounts({ admin: $.admin.publicKey }) + .signers([$.admin]) + .rpc(), + "InvalidParam" + ); + }); + + // given the admin signs the transaction + // given the new replace authority is not in the list + // it adds the new replace authority to the list + // it resizes the ext global account to accommodate the new replace authority + test("new replace authority is not in the list - success", async () => { + // Cache the size of the ext global account + const extGlobalAccount = $.getExtGlobalAccount(); + const extGlobalSize = await $.provider.connection + .getAccountInfo(extGlobalAccount) + .then((info) => info?.data.length || 0); + + // Send the transaction + await $.ext.methods + .addReplaceAuthority($.nonWrapAuthority.publicKey) + .accounts({ + admin: $.admin.publicKey, + }) + .signers([$.admin]) + .rpc(); + + // Check that the replace authority was added + replaceAuthorities.push($.nonWrapAuthority.publicKey); + + await $.expectExtGlobalState({ + replaceAuthorities, + }); + + // Check that the ext global account was resized + const newExtGlobalSize = await $.provider.connection + .getAccountInfo(extGlobalAccount) + .then((info) => info?.data.length || 0); + expect(newExtGlobalSize).toEqual(extGlobalSize + 32); // 32 bytes for the new replace authority + }); + }); + + describe("remove_replace_authority unit tests", () => { + // test cases + // [X] given the admin does not sign the transaction + // [X] it reverts with a NotAuthorized error + // [X] given the admin signs the transaction + // [X] given the replace authority is not in the list + // [X] it reverts with a InvalidParam error + // [X] given the replace authority is in the list + // [X] it removes the replace authority from the list + // [X] it resizes the ext global account down and refunds lamports + + // given the admin does not sign the transaction + // it reverts with a NotAuthorized error + test("admin does not sign - reverts", async () => { + await $.expectAnchorError( + $.ext.methods + .removeReplaceAuthority($.wrapAuthority.publicKey) + .accounts({ + admin: $.nonAdmin.publicKey, + }) + .signers([$.nonAdmin]) + .rpc(), + "NotAuthorized" + ); + }); + + // given the admin signs the transaction + // given the replace authority is not in the list + // it reverts with a InvalidParam error + test("replace authority not in the list - reverts", async () => { + await $.expectAnchorError( + $.ext.methods + .removeReplaceAuthority($.nonWrapAuthority.publicKey) + .accounts({ admin: $.admin.publicKey }) + .signers([$.admin]) + .rpc(), + "InvalidParam" + ); + }); + + // given the admin signs the transaction + // given the replace authority is in the list + // it removes the replace authority from the list + // it resizes the ext global account down and refunds lamports + test("replace authority is in the list - success", async () => { + // Cache the size of the ext global account + const extGlobalAccount = $.getExtGlobalAccount(); + const extGlobalSize = await $.provider.connection + .getAccountInfo(extGlobalAccount) + .then((info) => info?.data.length || 0); + + // Send the transaction + await $.ext.methods + .removeReplaceAuthority($.wrapAuthority.publicKey) + .accounts({ + admin: $.admin.publicKey, + }) + .signers([$.admin]) + .rpc(); + + // Check that the replace authority was removed + replaceAuthorities.pop(); + + await $.expectExtGlobalState({ + replaceAuthorities, + }); + + // Check that the ext global account was resized + const newExtGlobalSize = await $.provider.connection + .getAccountInfo(extGlobalAccount) + .then((info) => info?.data.length || 0); + expect(newExtGlobalSize).toEqual(extGlobalSize - 32); // remove 32 bytes + }); + }); + + describe("set_asset_cap unit tests", () => { + // set_asset_cap test cases + // [x] given the admin does not sign the transaction + // [x] it reverts with a NotAuthorized error + // [x] given the asset mint is the M token + // [x] it reverts with a CannotCapMToken error + // [x] given the asset mint does not have 6 decimals + // [x] it reverts with an InvalidDecimals error + // [x] given valid inputs and admin signs + // [x] new assetConfig asset cap is set successfully + // [x] existing assetConfig asset cap is updated successfully + // [x] it can set the cap to 0 to disable the asset + // [x] given the global account is not the global PDA + // [x] it reverts with a ConstraintSeeds error + // [x] given the vault authority is not the m_vault PDA + // [x] it reverts with a ConstraintSeeds error + // [x] given the asset mint has the ScaledUiAmount extension + // [x] it reverts with an UnsupportedExtension error + // [x] given the asset mint has the TransferFeeConfig extension + // [x] it reverts with an UnsupportedExtension error + // [x] given the asset mint has the NonTransferable extension + // [x] it reverts with an UnsupportedExtension error + // [x] given the asset mint has the InterestBearingConfig extension + // [x] it reverts with an UnsupportedExtension error + + let assetMint: Keypair; + + beforeEach(async () => { + assetMint = await $.createAssetMint(6); + }); + + // given the admin does not sign the transaction + // it reverts with a NotAuthorized error + test("Non-admin tries to set asset cap - reverts", async () => { + await $.expectAnchorError( + $.ext.methods + .setAssetCap(new BN(1_000_000_000)) + .accountsPartial({ + admin: $.nonAdmin.publicKey, + assetMint: assetMint.publicKey, + assetTokenProgram: TOKEN_PROGRAM_ID, + }) + .signers([$.nonAdmin]) + .rpc(), + "NotAuthorized" + ); + }); + + // given the asset mint is the M token + // it reverts with a CannotCapMToken error + test("Asset mint is M token - reverts", async () => { + await $.expectAnchorError( + $.ext.methods + .setAssetCap(new BN(1_000_000_000)) + .accountsPartial({ + admin: $.admin.publicKey, + assetMint: $.mMint.publicKey, + assetTokenProgram: TOKEN_2022_PROGRAM_ID, + }) + .signers([$.admin]) + .rpc(), + "CannotCapMToken" + ); + }); + + // given the asset mint does not have 6 decimals + // it reverts with an InvalidDecimals error + test("Asset mint with wrong decimals - reverts", async () => { + const wrongDecimalsMint = await $.createAssetMint(9); + + await $.expectAnchorError( + $.ext.methods + .setAssetCap(new BN(1_000_000_000)) + .accountsPartial({ + admin: $.admin.publicKey, + assetMint: wrongDecimalsMint.publicKey, + assetTokenProgram: TOKEN_PROGRAM_ID, + }) + .signers([$.admin]) + .rpc(), + "InvalidDecimals" + ); + }); + + // given valid inputs + // it creates the asset_config account and sets the cap + test("Set asset cap - success", async () => { + const cap = new BN(1_000_000_000); + await $.setAssetCap(assetMint.publicKey, cap); + + await $.expectAssetConfigState(assetMint.publicKey, { + cap: cap, + }); + + // Verify the vault asset ATA was created + const vaultAssetATA = await $.getATA( + assetMint.publicKey, + $.getMVault(), + false + ); + // Verify it has zero balance (just created) + await $.expectTokenBalance( + vaultAssetATA, + new BN(0), + Comparison.Equal, + undefined, + false + ); + }); + + // it can update an existing cap to a new value + test("Update existing asset cap - success", async () => { + const initialCap = new BN(1_000_000_000); + await $.setAssetCap(assetMint.publicKey, initialCap); + + await $.expectAssetConfigState(assetMint.publicKey, { + cap: initialCap, + }); + + const newCap = new BN(2_000_000_000); + await $.setAssetCap(assetMint.publicKey, newCap); + + await $.expectAssetConfigState(assetMint.publicKey, { + cap: newCap, + }); + }); + + // it can set the cap to 0 to disable the asset + test("Set cap to zero to disable asset - success", async () => { + const cap = new BN(1_000_000_000); + await $.setAssetCap(assetMint.publicKey, cap); + await $.expectAssetConfigState(assetMint.publicKey, { + cap: cap, + }); + + await $.setAssetCap(assetMint.publicKey, new BN(0)); + await $.expectAssetConfigState(assetMint.publicKey, { + cap: new BN(0), + }); + }); + + // given the global account is not the global PDA + // it reverts with a ConstraintSeeds error + test("Global account is not the global PDA - reverts", async () => { + const fakeGlobal = PublicKey.unique(); + if (fakeGlobal === $.getExtGlobalAccount()) return; + + // Copy the real global account data to the fake address so it deserializes properly + const realGlobalInfo = $.svm.getAccount($.getExtGlobalAccount()); + if (realGlobalInfo) { + $.svm.setAccount(fakeGlobal, realGlobalInfo); + } + + await $.expectAnchorError( + $.ext.methods + .setAssetCap(new BN(1_000_000_000)) + .accountsPartial({ + admin: $.admin.publicKey, + assetMint: assetMint.publicKey, + globalAccount: fakeGlobal, + assetTokenProgram: TOKEN_PROGRAM_ID, + }) + .signers([$.admin]) + .rpc(), + "ConstraintSeeds" + ); + }); + + // given the vault authority is not the m_vault PDA + // it reverts with a ConstraintSeeds error + test("Vault authority is not the m_vault PDA - reverts", async () => { + const fakeVault = PublicKey.unique(); + + await $.expectAnchorError( + $.ext.methods + .setAssetCap(new BN(1_000_000_000)) + .accountsPartial({ + admin: $.admin.publicKey, + assetMint: assetMint.publicKey, + vaultAuthority: fakeVault, + assetTokenProgram: TOKEN_PROGRAM_ID, + }) + .signers([$.admin]) + .rpc(), + "ConstraintSeeds" + ); + }); + + // given the asset mint has the ScaledUiAmount extension + // it reverts with an UnsupportedExtension error + test("Asset mint with ScaledUiAmount extension - reverts", async () => { + const scaledMint = new Keypair(); + await $.createScaledUiMint( + scaledMint, + $.admin.publicKey, + $.admin.publicKey, + 6 + ); + + await $.expectAnchorError( + $.ext.methods + .setAssetCap(new BN(1_000_000_000)) + .accountsPartial({ + admin: $.admin.publicKey, + assetMint: scaledMint.publicKey, + assetTokenProgram: TOKEN_2022_PROGRAM_ID, + }) + .signers([$.admin]) + .rpc(), + "UnsupportedExtension" + ); + }); + + // given the asset mint has the TransferFeeConfig extension + // it reverts with an UnsupportedExtension error + test("Asset mint with TransferFeeConfig extension - reverts", async () => { + const transferFeeMint = new Keypair(); + await $.createTransferFeeMint( + transferFeeMint, + $.admin.publicKey, + $.admin.publicKey, + 6 + ); + + await $.expectAnchorError( + $.ext.methods + .setAssetCap(new BN(1_000_000_000)) + .accountsPartial({ + admin: $.admin.publicKey, + assetMint: transferFeeMint.publicKey, + assetTokenProgram: TOKEN_2022_PROGRAM_ID, + }) + .signers([$.admin]) + .rpc(), + "UnsupportedExtension" + ); + }); + + // given the asset mint has the NonTransferable extension + // it reverts with an UnsupportedExtension error + test("Asset mint with NonTransferable extension - reverts", async () => { + const nonTransferableMint = new Keypair(); + await $.createNonTransferableMint( + nonTransferableMint, + $.admin.publicKey, + $.admin.publicKey, + 6 + ); + + await $.expectAnchorError( + $.ext.methods + .setAssetCap(new BN(1_000_000_000)) + .accountsPartial({ + admin: $.admin.publicKey, + assetMint: nonTransferableMint.publicKey, + assetTokenProgram: TOKEN_2022_PROGRAM_ID, + }) + .signers([$.admin]) + .rpc(), + "UnsupportedExtension" + ); + }); + + // given the asset mint has the InterestBearingConfig extension + // it reverts with an UnsupportedExtension error + test("Asset mint with InterestBearingConfig extension - reverts", async () => { + const interestBearingMint = new Keypair(); + await $.createInterestBearingMint( + interestBearingMint, + $.admin.publicKey, + $.admin.publicKey, + 6 + ); + + await $.expectAnchorError( + $.ext.methods + .setAssetCap(new BN(1_000_000_000)) + .accountsPartial({ + admin: $.admin.publicKey, + assetMint: interestBearingMint.publicKey, + assetTokenProgram: TOKEN_2022_PROGRAM_ID, + }) + .signers([$.admin]) + .rpc(), + "UnsupportedExtension" + ); + }); + }); + + describe("wrap_asset unit tests", () => { + // wrap_asset test cases + // [x] given the caller is not authorized (not in wrap_authorities) + // [x] it reverts with a NotAuthorized error + // [x] given an authorized caller signs + // [x] given the amount is 0 + // [x] it reverts with an InvalidAmount error + // [x] given the asset config cap is 0 (disabled) + // [x] it reverts with an AssetNotAllowed error + // [x] given the wrap would exceed the asset cap + // [x] it reverts with an AssetCapExceeded error + // [x] given an authorized caller signs & valid inputs + // [x] it transfers assets from user to vault + // [x] it increments total_assets on the global account + // [x] wraps multiple assets correctly + // [x] given the asset mint is the M token + // [x] it reverts with a ConstraintSeeds error (no asset config can exist for M) + // [x] given the asset config does not exist for this asset + // [x] it reverts with an AccountNotInitialized error + + let assetMint: Keypair; + let fromAssetTokenAccount: PublicKey; + let toExtTokenAccount: PublicKey; + + const assetCap = new BN(1_000_000_000); // 1000 tokens + const mintAmount = new BN(100_000_000); // 100 tokens + + beforeEach(async () => { + // Setup asset with cap + const setup = await $.setupJmiAsset(assetCap); + assetMint = setup.assetMint; + + // Mint assets to wrap authority + await $.mintAssetTokens(assetMint, $.wrapAuthority.publicKey, mintAmount); + + fromAssetTokenAccount = await $.getATA( + assetMint.publicKey, + $.wrapAuthority.publicKey, + false + ); + + toExtTokenAccount = await $.getATA( + $.extMint.publicKey, + $.wrapAuthority.publicKey, + $.useToken2022ForExt + ); + + }); + + // given the caller is not authorized (not in wrap_authorities) + // it reverts with a NotAuthorized error + test("Non-authorized caller tries to wrap asset - reverts", async () => { + + // Mint some assets to the non-wrap authority + await $.mintAssetTokens(assetMint, $.nonWrapAuthority.publicKey, mintAmount); + + fromAssetTokenAccount = await $.getATA( + assetMint.publicKey, + $.nonWrapAuthority.publicKey, + false + ); + + toExtTokenAccount = await $.getATA( + $.extMint.publicKey, + $.nonWrapAuthority.publicKey, + $.useToken2022ForExt + ); + + await $.expectAnchorError( + $.ext.methods + .wrapAsset(new BN(10_000_000)) + .accountsPartial({ + tokenAuthority: $.nonWrapAuthority.publicKey, + replaceAuthority: $.ext.programId, + assetMint: assetMint.publicKey, + assetConfig: $.getAssetConfigAccount(assetMint.publicKey), + fromAssetTokenAccount, + toExtTokenAccount, + assetTokenProgram: TOKEN_PROGRAM_ID, + extTokenProgram: $.extTokenProgram, + }) + .signers([$.nonWrapAuthority]) + .rpc(), + "NotAuthorized" + ); + }); + + // given the amount is 0 + // it reverts with an InvalidAmount error + test("Wrap asset with zero amount - reverts", async () => { + await $.expectAnchorError( + $.ext.methods + .wrapAsset(new BN(0)) + .accountsPartial({ + tokenAuthority: $.wrapAuthority.publicKey, + replaceAuthority: $.ext.programId, + assetMint: assetMint.publicKey, + assetConfig: $.getAssetConfigAccount(assetMint.publicKey), + fromAssetTokenAccount, + toExtTokenAccount, + assetTokenProgram: TOKEN_PROGRAM_ID, + extTokenProgram: $.extTokenProgram, + }) + .signers([$.wrapAuthority]) + .rpc(), + "InvalidAmount" + ); + }); + + // given the asset config cap is 0 (disabled) + // it reverts with an AssetCapExceeded error + test("Wrap asset with zero cap - reverts", async () => { + // Disable the asset + await $.setAssetCap(assetMint.publicKey, new BN(0)); + + await $.expectAnchorError( + $.ext.methods + .wrapAsset(new BN(10_000_000)) + .accountsPartial({ + tokenAuthority: $.wrapAuthority.publicKey, + replaceAuthority: $.ext.programId, + assetMint: assetMint.publicKey, + assetConfig: $.getAssetConfigAccount(assetMint.publicKey), + fromAssetTokenAccount, + toExtTokenAccount, + assetTokenProgram: TOKEN_PROGRAM_ID, + extTokenProgram: $.extTokenProgram, + }) + .signers([$.wrapAuthority]) + .rpc(), + "AssetCapExceeded" + ); + }); + + // given the wrap would exceed the asset cap + // it reverts with an AssetCapExceeded error + test("Wrap asset exceeding cap - reverts", async () => { + // Set a small cap + await $.setAssetCap(assetMint.publicKey, new BN(10_000_000)); + + await $.expectAnchorError( + $.ext.methods + .wrapAsset(new BN(20_000_000)) // Exceeds cap + .accountsPartial({ + tokenAuthority: $.wrapAuthority.publicKey, + replaceAuthority: $.ext.programId, + assetMint: assetMint.publicKey, + assetConfig: $.getAssetConfigAccount(assetMint.publicKey), + fromAssetTokenAccount, + toExtTokenAccount, + assetTokenProgram: TOKEN_PROGRAM_ID, + extTokenProgram: $.extTokenProgram, + }) + .signers([$.wrapAuthority]) + .rpc(), + "AssetCapExceeded" + ); + }); + + // given valid inputs + // it transfers assets from user to vault and mints ext tokens 1:1 + test("Wrap asset - success", async () => { + const wrapAmount = new BN(10_000_000); + + // Get initial balances + const userAssetAccount = await $.getATA( + assetMint.publicKey, + $.wrapAuthority.publicKey, + false + ); + const userExtAccount = await $.getATA( + $.extMint.publicKey, + $.wrapAuthority.publicKey, + $.useToken2022ForExt + ); + const vaultAssetAccount = await $.getATA( + assetMint.publicKey, + $.getMVault(), + false + ); + + const initialUserAssetBalance = await $.getTokenBalance(userAssetAccount, false); + const initialUserExtBalance = await $.getTokenBalance(userExtAccount, $.useToken2022ForExt); + const initialVaultAssetBalance = await $.getTokenBalance(vaultAssetAccount, false); + + // Wrap assets + await $.wrapAsset(assetMint.publicKey, wrapAmount, $.wrapAuthority); + + // Verify balances + await $.expectTokenBalance( + userAssetAccount, + initialUserAssetBalance.sub(wrapAmount), + Comparison.Equal, + undefined, + false + ); + await $.expectTokenBalance( + userExtAccount, + initialUserExtBalance.add(wrapAmount), + Comparison.Equal, + undefined, + $.useToken2022ForExt + ); + await $.expectTokenBalance( + vaultAssetAccount, + initialVaultAssetBalance.add(wrapAmount), + Comparison.Equal, + undefined, + false + ); + + // Verify total_assets updated + await $.expectExtGlobalState({ + yieldConfig: { + totalAssets: wrapAmount, + }, + }); + }); + + // Multiple wraps accumulate total_assets + test("Multiple wrap assets - success", async () => { + const wrapAmount1 = new BN(10_000_000); + const wrapAmount2 = new BN(20_000_000); + + await $.wrapAsset(assetMint.publicKey, wrapAmount1, $.wrapAuthority); + await $.wrapAsset(assetMint.publicKey, wrapAmount2, $.wrapAuthority); + + await $.expectExtGlobalState({ + yieldConfig: { + totalAssets: wrapAmount1.add(wrapAmount2), + }, + }); + }); + + // given the asset mint is the M token + // it reverts with a ConstraintSeeds error (asset config PDA doesn't match) + test("Wrap asset with M token as asset - reverts", async () => { + // Add wrap authority as M earner to unfreeze the ATA (M token has frozen default state) + await $.addMEarner($.wrapAuthority.publicKey); + // Mint M tokens to wrap authority + await $.mintM($.wrapAuthority.publicKey, mintAmount); + + const fromMTokenAccount = await $.getATA( + $.mMint.publicKey, + $.wrapAuthority.publicKey, + true // M uses Token2022 + ); + + // Create a fake AssetConfig account at wrong address to trigger ConstraintSeeds + const fakeAssetConfig = PublicKey.unique(); + + // Copy real AssetConfig data to the fake address so it deserializes properly + const realAssetConfigInfo = $.svm.getAccount( + $.getAssetConfigAccount(assetMint.publicKey) + ); + if (realAssetConfigInfo) { + $.svm.setAccount(fakeAssetConfig, realAssetConfigInfo); + } + + await $.expectAnchorError( + $.ext.methods + .wrapAsset(new BN(10_000_000)) + .accountsPartial({ + tokenAuthority: $.wrapAuthority.publicKey, + replaceAuthority: $.ext.programId, + assetMint: $.mMint.publicKey, + assetConfig: fakeAssetConfig, // Use fake account instead of PDA + fromAssetTokenAccount: fromMTokenAccount, + toExtTokenAccount, + assetTokenProgram: TOKEN_2022_PROGRAM_ID, + extTokenProgram: $.extTokenProgram, + }) + .signers([$.wrapAuthority]) + .rpc(), + "AssetNotAllowed" + ); + }); + + // given the asset config does not exist for this asset + // it reverts with a AccountNotInitialized error + test("Wrap asset with non-existent asset config - reverts", async () => { + // Create asset mint WITHOUT setting up asset config + const noConfigMint = await $.createAssetMint(); + + // Mint tokens to wrap authority + await $.mintAssetTokens(noConfigMint, $.wrapAuthority.publicKey, mintAmount); + + const fromAssetTokenAccount = await $.getATA( + noConfigMint.publicKey, + $.wrapAuthority.publicKey, + false + ); + + await $.expectAnchorError( + $.ext.methods + .wrapAsset(new BN(10_000_000)) + .accountsPartial({ + tokenAuthority: $.wrapAuthority.publicKey, + replaceAuthority: $.ext.programId, + assetMint: noConfigMint.publicKey, + assetConfig: $.getAssetConfigAccount(noConfigMint.publicKey), + fromAssetTokenAccount, + toExtTokenAccount, + assetTokenProgram: TOKEN_PROGRAM_ID, + extTokenProgram: $.extTokenProgram, + }) + .signers([$.wrapAuthority]) + .rpc(), + "AccountNotInitialized" + ); + }); + }); + + describe("replace_asset_with_m unit tests", () => { + // replace_asset_with_m test cases + // [x] given the caller is not authorized (not in wrap_authorities) + // [x] it reverts with a NotAuthorized error + // [x] given an authorized caller signs the transaction + // [x] given the m_amount is 0 + // [x] it reverts with an InvalidAmount error + // [x] given the vault has insufficient asset backing for the conversion + // [x] it reverts with an InsufficientAssetBacking error + // [x] given valid inputs + // [x] it transfers M from user to vault + // [x] it transfers asset from vault to user + // [x] it decrements total_assets on the global account + + let assetMint: Keypair; + const assetCap = new BN(1_000_000_000); // 1000 tokens + const mintAmount = new BN(100_000_000); // 100 tokens + const wrapAmount = new BN(50_000_000); // 50 tokens + + beforeEach(async () => { + // Setup asset with cap + const setup = await $.setupJmiAsset(assetCap); + assetMint = setup.assetMint; + + // Add the wrap authority and as M earners to have thawed token accounts + await $.addMEarner($.wrapAuthority.publicKey); + await $.addMEarner($.nonWrapAuthority.publicKey); + + // Mint assets and wrap some to create initial state + await $.mintAssetTokens(assetMint, $.wrapAuthority.publicKey, mintAmount); + await $.wrapAsset(assetMint.publicKey, wrapAmount, $.wrapAuthority); + + // Mint M to wrap authority for replace_asset_with_m + await $.mintM($.wrapAuthority.publicKey, mintAmount); + }); + + // given the caller is not authorized (not in wrap_authorities) + // it reverts with a NotAuthorized error + test("Non-authorized caller tries to unwrap asset - reverts", async () => { + // Mint M to non-wrap authority + await $.mintM($.nonWrapAuthority.publicKey, mintAmount); + + // Create asset token account for non-wrap authority + await $.getATA(assetMint.publicKey, $.nonWrapAuthority.publicKey, false); + + let fromMTokenAccount = await $.getATA( + $.mMint.publicKey, + $.nonWrapAuthority.publicKey, + ); + + let toAssetTokenAccount = await $.getATA( + assetMint.publicKey, + $.nonWrapAuthority.publicKey, + false + ); + + await $.expectAnchorError( + $.ext.methods + .replaceAssetWithM(new BN(10_000_000)) + .accountsPartial({ + tokenAuthority: $.nonWrapAuthority.publicKey, + replaceAuthority: $.ext.programId, + mMint: $.mMint.publicKey, + assetMint: assetMint.publicKey, + assetConfig: $.getAssetConfigAccount(assetMint.publicKey), + fromMTokenAccount, + toAssetTokenAccount, + assetTokenProgram: TOKEN_PROGRAM_ID, + mTokenProgram: TOKEN_2022_PROGRAM_ID, + }) + .signers([$.nonWrapAuthority]) + .rpc(), + "NotAuthorized" + ); + }); + + // given the m_amount is 0 + // it reverts with an InvalidAmount error + test("Unwrap asset with zero M amount - reverts", async () => { + + let fromMTokenAccount = await $.getATA( + $.mMint.publicKey, + $.wrapAuthority.publicKey, + ); + + let toAssetTokenAccount = await $.getATA( + assetMint.publicKey, + $.wrapAuthority.publicKey, + false + ); + + await $.expectAnchorError( + $.ext.methods + .replaceAssetWithM(new BN(0)) + .accountsPartial({ + tokenAuthority: $.wrapAuthority.publicKey, + replaceAuthority: $.ext.programId, + mMint: $.mMint.publicKey, + assetMint: assetMint.publicKey, + assetConfig: $.getAssetConfigAccount(assetMint.publicKey), + fromMTokenAccount, + toAssetTokenAccount, + assetTokenProgram: TOKEN_PROGRAM_ID, + mTokenProgram: TOKEN_2022_PROGRAM_ID, + }) + .signers([$.wrapAuthority]) + .rpc(), + "InvalidAmount" + ); + }); + + // given the vault has insufficient asset backing for the conversion + // it reverts with an InsufficientAssetBacking error + test("Unwrap asset with insufficient backing - reverts", async () => { + // Try to unwrap more than the vault has + const largeAmount = wrapAmount.mul(new BN(10)); // Way more than vault has + + let fromMTokenAccount = await $.getATA( + $.mMint.publicKey, + $.wrapAuthority.publicKey, + ); + + let toAssetTokenAccount = await $.getATA( + assetMint.publicKey, + $.wrapAuthority.publicKey, + false + ); + + await $.expectAnchorError( + $.ext.methods + .replaceAssetWithM(largeAmount) + .accountsPartial({ + tokenAuthority: $.wrapAuthority.publicKey, + replaceAuthority: $.ext.programId, + mMint: $.mMint.publicKey, + assetMint: assetMint.publicKey, + assetConfig: $.getAssetConfigAccount(assetMint.publicKey), + fromMTokenAccount, + toAssetTokenAccount, + assetTokenProgram: TOKEN_PROGRAM_ID, + mTokenProgram: TOKEN_2022_PROGRAM_ID, + }) + .signers([$.wrapAuthority]) + .rpc(), + "InsufficientAssetBacking" + ); + }); + + // given valid inputs - success + test("Unwrap asset - success", async () => { + // Use a smaller amount that fits within the vault's backing + // M index is ~1.1, so mAmount of 10M should give ~9M assets + const mAmount = new BN(10_000_000); + + // Get initial balances + const userMAccount = await $.getATA( + $.mMint.publicKey, + $.wrapAuthority.publicKey + ); + const userAssetAccount = await $.getATA( + assetMint.publicKey, + $.wrapAuthority.publicKey, + false + ); + const vaultMAccount = await $.getATA($.mMint.publicKey, $.getMVault()); + const vaultAssetAccount = await $.getATA( + assetMint.publicKey, + $.getMVault(), + false + ); + + const initialUserMBalance = await $.getTokenBalance(userMAccount); + const initialUserAssetBalance = await $.getTokenBalance(userAssetAccount, false); + const initialVaultMBalance = await $.getTokenBalance(vaultMAccount); + const initialVaultAssetBalance = await $.getTokenBalance(vaultAssetAccount, false); + const globalAccount = await $.ext.account.extGlobalV2.fetch($.getExtGlobalAccount()); + const initialTotalAssets = (globalAccount.yieldConfig as any).totalAssets as BN; + + // Unwrap assets + await $.replaceAssetWithM(assetMint.publicKey, mAmount, $.wrapAuthority); + + // Verify M transferred to vault + await $.expectTokenBalance( + userMAccount, + initialUserMBalance.sub(mAmount) + ); + await $.expectTokenBalance( + vaultMAccount, + initialVaultMBalance.add(mAmount) + ); + + // Verify assets transferred from vault (amount depends on M index) + const userAssetBalanceAfter = await $.getTokenBalance(userAssetAccount, false); + const vaultAssetBalanceAfter = await $.getTokenBalance(vaultAssetAccount, false); + const assetTransferred = initialVaultAssetBalance.sub(vaultAssetBalanceAfter); + + expect(assetTransferred.gt(new BN(0))).toBe(true); + expect(userAssetBalanceAfter.sub(initialUserAssetBalance).toString()).toBe( + assetTransferred.toString() + ); + + // Verify total_assets decremented + await $.expectExtGlobalState({ + yieldConfig: { + totalAssets: initialTotalAssets.sub(assetTransferred), + }, + }); + }); + + // given valid inputs + // it converts M amount to asset amount using M index (rounds down) + test("M amount converts to asset amount using M index (rounds down)", async () => { + + // Get M index from M token's ScaledUiAmountConfig (not ext's multiplier) + const mScaledUiConfig = await $.getScaledUiAmountConfig($.mMint.publicKey); + const mIndex = new BN(Math.floor(mScaledUiConfig.newMultiplier * 1e12)); + expect(mIndex.toString()).toBe(new BN("1100000000000").toString()); // Sanity check mIndex not 1 + const INDEX_SCALE = new BN("1000000000000"); // 1e12 -> taken from constants + + // Get user's asset token account + const userAssetAta = await $.getATA(assetMint.publicKey, $.wrapAuthority.publicKey, false); + const initialAssetBalance = await $.getTokenBalance(userAssetAta, false); + + // Perform replace_asset_with_m with known M amount + const mAmount = new BN(5_000_000); // 5 tokens (6 decimals) + await $.replaceAssetWithM(assetMint.publicKey, mAmount, $.wrapAuthority); + + // Calculate expected asset amount (rounding down) + const expectedAssetAmount = mAmount.mul(mIndex).div(INDEX_SCALE); + + // Verify asset received matches expected (with index conversion) + const finalAssetBalance = await $.getTokenBalance(userAssetAta, false); + const assetReceived = finalAssetBalance.sub(initialAssetBalance); + expect(assetReceived.eq(expectedAssetAmount)).toBe(true); + }); + + describe("unwrap with JMI backing tests", () => { + // unwrap test cases (JMI-specific additions) + // [x] given the JMI feature is enabled + // [x] given ext_principal exceeds M backing (ext_supply - total_assets) + // [x] it reverts with an InsufficientMBacking error + // [x] given ext_principal equals available M backing + // [x] it succeeds and unwraps the full M backing + + let assetMint: Keypair; + const assetCap = new BN(1_000_000_000); + const mWrapAmount = new BN(50_000_000); // 50 tokens wrapped via M + const assetWrapAmount = new BN(30_000_000); // 30 tokens wrapped via asset + + let fromExtTokenAccount: PublicKey; + let toMTokenAccount: PublicKey; + + beforeEach(async () => { + // Setup asset with cap + const setup = await $.setupJmiAsset(assetCap); + assetMint = setup.assetMint; + + // Then setup and wrap assets + await $.expectAssetConfigState(assetMint.publicKey, { + cap: assetCap, + }); + + // First wrap M tokens + const feeBps = new BN(randomInt(10000)) + + await $.mintM($.wrapAuthority.publicKey, mWrapAmount); + await $.wrap($.wrapAuthority, mWrapAmount); + + await $.mintAssetTokens(assetMint, $.wrapAuthority.publicKey, assetWrapAmount.mul(new BN(2))); + await $.wrapAsset(assetMint.publicKey, assetWrapAmount, $.wrapAuthority); + + fromExtTokenAccount = await $.getATA( + $.extMint.publicKey, + $.wrapAuthority.publicKey, + $.useToken2022ForExt + ); + + toMTokenAccount = await $.getATA( + $.mMint.publicKey, + $.wrapAuthority.publicKey + ); + + }); + + // given ext_principal exceeds M backing + // it reverts with insufficient funds error from token program + test("Unwrap exceeding M backing - reverts", async () => { + // Get current ext balance (should have mWrapAmount + assetWrapAmount worth) + const userExtAccount = await $.getATA( + $.extMint.publicKey, + $.wrapAuthority.publicKey, + $.useToken2022ForExt + ); + const userExtBalance = await $.getTokenBalance(userExtAccount, $.useToken2022ForExt); + + // Try to unwrap entire ext balance - but only mWrapAmount is backed by M + // This should fail because some of the ext is backed by assets, not M + try { + await $.ext.methods + .unwrap(userExtBalance) + .accounts({ + tokenAuthority: $.wrapAuthority.publicKey, + unwrapAuthority: $.ext.programId, + fromExtTokenAccount, + toMTokenAccount, + extTokenProgram: $.extTokenProgram, + mTokenProgram: TOKEN_2022_PROGRAM_ID, + }) + .signers([$.wrapAuthority]) + .rpc(); + throw new Error("Transaction should have reverted"); + } catch (e) { + expect(String(e)).toContain("insufficient funds"); + } + }); + + // given ext_principal equals available M backing + // it succeeds and unwraps the full M backing + test("Unwrap exactly M backing - success", async () => { + // Unwrap exactly the M backing amount (mWrapAmount) + const userMAccountBefore = await $.getATA( + $.mMint.publicKey, + $.wrapAuthority.publicKey + ); + const mBalanceBefore = await $.getTokenBalance(userMAccountBefore); + + // Unwrap exactly mWrapAmount (the full M backing) + await $.unwrap($.wrapAuthority, mWrapAmount); + + // Verify M was received + const mBalanceAfter = await $.getTokenBalance(userMAccountBefore); + expect(mBalanceAfter.gt(mBalanceBefore)).toBe(true); + }); + }); + }); + }); + }; +}); +} +