diff --git a/.github/setup/action.yaml b/.github/setup/action.yaml index 45933ff..e0d763d 100644 --- a/.github/setup/action.yaml +++ b/.github/setup/action.yaml @@ -1,20 +1,20 @@ name: setup -description: 'Installing tooling and dependencies for running tests' +description: "Installing tooling and dependencies for running tests" inputs: node-version: - description: 'Node.js version' + description: "Node.js version" required: false - default: '22' + default: "22" solana-version: - description: 'Solana version' + description: "Solana version" required: false - default: '2.1.0' + default: "2.1.0" anchor-version: - description: 'Anchor CLI version' + description: "Anchor CLI version" required: false - default: '0.31.1' + default: "0.31.1" runs: - using: 'composite' + using: "composite" steps: - name: Setup Node uses: actions/setup-node@v4 @@ -61,5 +61,5 @@ runs: shell: bash - name: Build programs - run: anchor build - shell: bash \ No newline at end of file + run: make build-programs + shell: bash diff --git a/.github/workflows/anchor.yaml b/.github/workflows/anchor.yaml index aef6f1d..1965a20 100644 --- a/.github/workflows/anchor.yaml +++ b/.github/workflows/anchor.yaml @@ -15,8 +15,8 @@ concurrency: cancel-in-progress: true jobs: - scaled-ui-ext-tests: - runs-on: macos-latest + program-tests: + runs-on: macos-latest steps: - name: Checkout code uses: actions/checkout@v4 @@ -25,5 +25,5 @@ jobs: uses: ./.github/setup - name: Run tests - run: yarn run jest --preset ts-jest --verbose tests/unit/scaled_ui_ext.test.ts - shell: bash \ No newline at end of file + run: yarn test + shell: bash diff --git a/Anchor.toml b/Anchor.toml index 6d726c0..11d8e99 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -8,7 +8,8 @@ seeds = false skip-lint = false [programs.localnet] -scaled_ui_ext = "3C865D264L4NkAm78zfnDzQJJvXuU3fMjRUvRxyPi5da" +m_ext = "3C865D264L4NkAm78zfnDzQJJvXuU3fMjRUvRxyPi5da" +ext_swap = "MSwapi3WhNKMUGm9YrxGhypgUEt7wYQH3ZgG32XoWzH" [registry] url = "https://api.apr.dev" diff --git a/Cargo.lock b/Cargo.lock index a602bcd..c692cb7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -830,7 +830,7 @@ dependencies = [ [[package]] name = "earn" version = "0.1.0" -source = "git+https://github.com/m0-foundation/solana-m?branch=solana-2.1.0#58c8bac1e5130baa1f227982ba638180fabd8034" +source = "git+https://github.com/m0-foundation/solana-m?branch=develop#fb96d0521d4a187b33874936b21b22bb16505fec" dependencies = [ "anchor-lang", "anchor-spl", @@ -887,6 +887,17 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "ext_swap" +version = "0.1.0" +dependencies = [ + "anchor-lang", + "anchor-spl", + "earn", + "m_ext", + "solana-security-txt", +] + [[package]] name = "feature-probe" version = "0.1.1" @@ -1155,6 +1166,18 @@ version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +[[package]] +name = "m_ext" +version = "0.1.0" +dependencies = [ + "anchor-lang", + "anchor-spl", + "cfg-if", + "earn", + "solana-security-txt", + "spl-token-2022 7.0.0", +] + [[package]] name = "memchr" version = "2.7.4" @@ -1503,19 +1526,6 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" -[[package]] -name = "scaled_ui_ext" -version = "0.1.0" -dependencies = [ - "anchor-lang", - "anchor-spl", - "cfg-if", - "earn", - "solana-program", - "solana-security-txt", - "spl-token-2022 7.0.0", -] - [[package]] name = "scopeguard" version = "1.2.0" diff --git a/Cargo.toml b/Cargo.toml index 020b5cf..5feb8b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,8 +17,7 @@ codegen-units = 1 anchor-lang = "0.31.1" anchor-spl = "0.31.1" spl-token-2022 = { version = "7.0.0", features = ["no-entrypoint"] } -solana-program = "=2.1.0" solana-security-txt = "1.1.1" cfg-if = "1.0" -earn = { git = "https://github.com/m0-foundation/solana-m", branch = "solana-2.1.0", features = ["no-entrypoint"] } +earn = { git = "https://github.com/m0-foundation/solana-m", branch = "develop", features = ["no-entrypoint"] } diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6c80dcd --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +build-programs: + anchor build -p ext_swap + anchor build -p m_ext -- --features scaled-ui --no-default-features + @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 + @cp target/deploy/m_ext.so target/deploy/no_yield.so + @cp target/idl/m_ext.json target/idl/no_yield.json + @cp target/types/m_ext.ts target/types/no_yield.ts + +test-programs: + @yarn run jest --preset ts-jest --verbose tests/unit/**.test.ts + @cargo test + +define update-program-id + @sed -i '' 's/declare_id!("[^"]*")/declare_id!("$(1)")/' programs/m_ext/src/lib.rs +endef + +build-test-programs: + $(call update-program-id,3joDhmLtHLrSBGfeAe1xQiv3gjikes3x8S4N3o6Ld8zB) + anchor build -p m_ext + @mv target/deploy/m_ext.so tests/programs/ext_a.so + $(call update-program-id,HSMnbWEkB7sEQAGSzBPeACNUCXC9FgNeeESLnHtKfoy3) + anchor build -p m_ext + @mv target/deploy/m_ext.so tests/programs/ext_b.so + $(call update-program-id,81gYpXqg8ZT9gdkFSe35eqiitqBWqVfYwDwVfXuk8Xfw) + sed -i '' '/pub ext_token_program: Program<'\''info, Token2022>,/a\'$$'\n''\ pub dummy_account: Program<'\''info, Token2022>,' programs/m_ext/src/instructions/wrap.rs + cargo fmt + anchor build -p m_ext --skip-lint + @mv target/deploy/m_ext.so tests/programs/ext_c.so + sed -i '' '/pub dummy_account: Program<'\''info, Token2022>,/d' programs/m_ext/src/instructions/wrap.rs + $(call update-program-id,3C865D264L4NkAm78zfnDzQJJvXuU3fMjRUvRxyPi5da) + anchor build -p m_ext diff --git a/README.md b/README.md index e2b1bd2..782c65c 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,20 @@ # Solana M Extension Programs -The programs in this repository implement different version of an "M Extension", which is a stablecoin backed by M. + +The `m_ext` program in this repository implements different versions of an "M Extension", which is a stablecoin backed by $M. The versions have a shared codebase for the majority of the code and use Rust feature flags to implement version-specific logic. The program relies on the underlying yield distribution of the $M token on Solana, which can be found in the [solana-m repository](https://github.com/m0-foundation/solana-m). ## Extensions + The list of implemented extensions is: + +- NoYield - no yield is distributed to extension holders. - ScaledUiAmount - yield is distributed to all extension token holders using the Token2022 ScaledUiAmount "rebasing" functionality. +## Swap Facility + +The `ext_swap` program creates a router that allows users to convert between any M extension that follows the `wrap` and `unwrap` interfaces specified in the `m_ext` program without receiving $M as an intermediate step. + ## Development + The Solana programs in this repository are built using Anchor. The required toolchain is specified in the Anchor.toml file. Use [`agave-install`](https://docs.anza.xyz/cli/install) to install and initialize the correct Solana CLI & runtime (`2.1.0`). @@ -14,6 +23,6 @@ Then, use [`avm`](https://www.anchor-lang.com/docs/installation) to install and Finally, the tests are written in Typescript using the LiteSVM framework. The javascript package manager is `yarn`. Install the required dependencies with `yarn install`. -The programs can then be built with: `anchor build` +The programs can then be built with: `make build-programs`. This will compile each variant of the `m_ext` program and save the bytecode plus the IDL in the target folder with the name of the extension. Yield features are not compatible with each other and only one can be selected. -The tests can be run with `yarn test`. If editing programs between test runs, be sure to recompile as the test runner doesn't do so automatically, i.e. `anchor build && yarn test`. \ No newline at end of file +The tests can be run with `make test-programs`. If editing programs between test runs, be sure to recompile as the test runner doesn't do so automatically, i.e. `make build-programs && make build-test-programs && make test-programs`. diff --git a/package.json b/package.json index 3068cb7..441ee31 100644 --- a/package.json +++ b/package.json @@ -1,29 +1,29 @@ { - "scripts": { - "lint:fix": "prettier */*.js \"*/**/*{.js,.ts}\" -w", - "lint": "prettier */*.js \"*/**/*{.js,.ts}\" --check", - "test": "yarn run jest --preset ts-jest --verbose", - "build": "anchor build" - }, - "dependencies": { - "@coral-xyz/anchor": "^0.31.1", - "@m0-foundation/solana-m-sdk": "https://gitpkg.vercel.app/m0-foundation/solana-m/sdk?0991eb829a52f6ba5969fd47d3100ef6871f0a6d&scripts.postinstall=yarn%20build", - "@solana-developers/helpers": "^2.7.0", - "@solana/spl-token": "^0.4.13", - "@solana/web3.js": "^1.98", - "anchor-litesvm": "0.1.2", - "bn.js": "^5.2.1", - "litesvm": "0.2.0" - }, - "devDependencies": { - "@types/bn.js": "^5.1.0", - "@types/jest": "^29.0.3", - "@types/node": "^22.13.13", - "jest": "^29.0.3", - "nock": "^14.0.2", - "prettier": "^2.6.2", - "ts-jest": "^29.0.2", - "ts-node": "^10.9.2", - "typescript": "5" - } + "scripts": { + "lint:fix": "prettier */*.js \"*/**/*{.js,.ts}\" -w", + "lint": "prettier */*.js \"*/**/*{.js,.ts}\" --check", + "test": "make test-programs", + "build": "make build-programs" + }, + "dependencies": { + "@coral-xyz/anchor": "^0.31.1", + "@m0-foundation/solana-m-sdk": "https://gitpkg.vercel.app/m0-foundation/solana-m/sdk?0991eb829a52f6ba5969fd47d3100ef6871f0a6d&scripts.postinstall=yarn%20build", + "@solana-developers/helpers": "^2.7.0", + "@solana/spl-token": "^0.4.13", + "@solana/web3.js": "^1.98", + "anchor-litesvm": "0.1.2", + "bn.js": "^5.2.1", + "litesvm": "0.2.0" + }, + "devDependencies": { + "@types/bn.js": "^5.1.0", + "@types/jest": "^29.0.3", + "@types/node": "^22.13.13", + "jest": "^29.0.3", + "nock": "^14.0.2", + "prettier": "^2.6.2", + "ts-jest": "^29.0.2", + "ts-node": "^10.9.2", + "typescript": "5" + } } diff --git a/programs/scaled_ui_ext/Cargo.toml b/programs/ext_swap/Cargo.toml similarity index 75% rename from programs/scaled_ui_ext/Cargo.toml rename to programs/ext_swap/Cargo.toml index 1388ac1..2d86117 100644 --- a/programs/scaled_ui_ext/Cargo.toml +++ b/programs/ext_swap/Cargo.toml @@ -1,26 +1,25 @@ [package] -name = "scaled_ui_ext" +name = "ext_swap" version = "0.1.0" description = "Created with Anchor" edition = "2021" [lib] crate-type = ["cdylib", "lib"] -name = "scaled_ui_ext" +name = "ext_swap" [features] +default = [] +cpi = ["no-entrypoint"] no-entrypoint = [] no-idl = [] no-log-ix-name = [] -cpi = ["no-entrypoint"] -default = [] idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"] + [dependencies] anchor-lang.workspace = true anchor-spl.workspace = true -spl-token-2022.workspace = true -cfg-if.workspace = true -solana-security-txt.workspace = true -solana-program.workspace = true earn.workspace = true +solana-security-txt.workspace = true +m_ext = {path = "../m_ext", features = ["cpi"]} diff --git a/programs/scaled_ui_ext/Xargo.toml b/programs/ext_swap/Xargo.toml similarity index 100% rename from programs/scaled_ui_ext/Xargo.toml rename to programs/ext_swap/Xargo.toml diff --git a/programs/ext_swap/src/errors.rs b/programs/ext_swap/src/errors.rs new file mode 100644 index 0000000..7162a3b --- /dev/null +++ b/programs/ext_swap/src/errors.rs @@ -0,0 +1,13 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum SwapError { + #[msg("Extension is not whitelisted")] + InvalidExtension, + #[msg("Extension is already whitelisted")] + AlreadyWhitelisted, + #[msg("Index invalid for length of the array")] + InvalidIndex, + #[msg("Signer is not whitelisted")] + UnauthorizedUnwrapper, +} diff --git a/programs/ext_swap/src/instructions/initialize.rs b/programs/ext_swap/src/instructions/initialize.rs new file mode 100644 index 0000000..93db600 --- /dev/null +++ b/programs/ext_swap/src/instructions/initialize.rs @@ -0,0 +1,34 @@ +use anchor_lang::prelude::*; + +use crate::state::{SwapGlobal, GLOBAL_SEED}; + +#[derive(Accounts)] +pub struct InitializeGlobal<'info> { + #[account(mut)] + pub admin: Signer<'info>, + + #[account( + init, + payer = admin, + space = SwapGlobal::size(0,0), + seeds = [GLOBAL_SEED], + bump, + )] + pub swap_global: Account<'info, SwapGlobal>, + + pub system_program: Program<'info, System>, +} + +impl InitializeGlobal<'_> { + pub fn handler(ctx: Context, m_mint: Pubkey) -> Result<()> { + ctx.accounts.swap_global.set_inner(SwapGlobal { + bump: ctx.bumps.swap_global, + admin: ctx.accounts.admin.key(), + m_mint: m_mint, + whitelisted_unwrappers: vec![], + whitelisted_extensions: vec![], + }); + + Ok(()) + } +} diff --git a/programs/ext_swap/src/instructions/mod.rs b/programs/ext_swap/src/instructions/mod.rs new file mode 100644 index 0000000..62a8a67 --- /dev/null +++ b/programs/ext_swap/src/instructions/mod.rs @@ -0,0 +1,11 @@ +pub mod initialize; +pub mod swap; +pub mod unwrap; +pub mod whitelist; +pub mod wrap; + +pub use initialize::*; +pub use swap::*; +pub use unwrap::*; +pub use whitelist::*; +pub use wrap::*; diff --git a/programs/ext_swap/src/instructions/swap.rs b/programs/ext_swap/src/instructions/swap.rs new file mode 100644 index 0000000..b3ca068 --- /dev/null +++ b/programs/ext_swap/src/instructions/swap.rs @@ -0,0 +1,286 @@ +use anchor_lang::prelude::*; +use anchor_spl::associated_token::AssociatedToken; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; +use earn::state::{Earner, EARNER_SEED}; +use m_ext::cpi::accounts::{Unwrap, Wrap}; +use m_ext::state::{EXT_GLOBAL_SEED, MINT_AUTHORITY_SEED, M_VAULT_SEED}; + +use crate::{ + errors::SwapError, + state::{SwapGlobal, GLOBAL_SEED}, +}; + +#[derive(Accounts)] +pub struct Swap<'info> { + #[account(mut)] + pub signer: Signer<'info>, + + // Required if the swap program is not whitelisted on the extension + pub wrap_authority: Option>, + pub unwrap_authority: Option>, + + /* + * Program globals + */ + #[account( + has_one = m_mint, + seeds = [GLOBAL_SEED], + bump = swap_global.bump, + )] + pub swap_global: Box>, + #[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>, + #[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>, + #[account( + seeds = [EARNER_SEED, from_m_vault.key().as_ref()], + seeds::program = earn::ID, + bump = from_m_earner.bump, + )] + pub from_m_earner: Box>, + #[account( + seeds = [EARNER_SEED, to_m_vault.key().as_ref()], + seeds::program = earn::ID, + bump = to_m_earner.bump, + )] + pub to_m_earner: Box>, + + /* + * Mints + */ + #[account(mut)] + pub from_mint: Box>, + #[account(mut)] + pub to_mint: Box>, + pub m_mint: Box>, + + /* + * Token Accounts + */ + #[account( + mut, + token::mint = from_mint, + token::token_program = to_token_program, + )] + pub from_token_account: Box>, + #[account( + init_if_needed, + payer = signer, + associated_token::mint = to_mint, + associated_token::authority = signer, + associated_token::token_program = to_token_program, + )] + pub to_token_account: Box>, + #[account( + init_if_needed, + payer = signer, + associated_token::mint = m_mint, + associated_token::authority = signer, + associated_token::token_program = m_token_program, + )] + pub intermediate_m_account: Box>, + + /* + * Authorities + */ + #[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 = [M_VAULT_SEED], + seeds::program = to_ext_program.key(), + bump, + )] + /// CHECK: account does not hold data + pub to_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( + 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 = m_mint, + associated_token::authority = from_m_vault_auth, + associated_token::token_program = m_token_program, + )] + pub from_m_vault: Box>, + #[account( + mut, + associated_token::mint = m_mint, + associated_token::authority = to_m_vault_auth, + associated_token::token_program = m_token_program, + )] + pub to_m_vault: Box>, + + /* + * Token Programs + */ + pub from_token_program: Interface<'info, TokenInterface>, + pub to_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 to_ext_program: UncheckedAccount<'info>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} + +impl<'info> Swap<'info> { + fn validate( + &self, + remaining_accounts: &[AccountInfo<'_>], + remaining_accounts_split_idx: usize, + ) -> Result<()> { + for ext_program in [&self.from_ext_program, &self.to_ext_program] { + if !self + .swap_global + .whitelisted_extensions + .contains(ext_program.key) + { + return err!(SwapError::InvalidExtension); + } + } + + if remaining_accounts_split_idx > remaining_accounts.len() { + return err!(SwapError::InvalidIndex); + } + + Ok(()) + } + + #[access_control(ctx.accounts.validate(ctx.remaining_accounts, remaining_accounts_split_idx))] + pub fn handler( + ctx: Context<'_, '_, '_, 'info, Self>, + amount: u64, + remaining_accounts_split_idx: usize, + ) -> Result<()> { + let m_pre_balance = ctx.accounts.intermediate_m_account.amount; + let to_pre_balance = ctx.accounts.to_token_account.amount; + + // Optional remaining accounts passed to the instructions + let remaining_accounts = ctx.remaining_accounts; + let (unwrap_remaining_accounts, wrap_remaining_accounts) = + remaining_accounts.split_at(remaining_accounts_split_idx); + + // Set swap program as authority if none provided + let unwrap_authority = match &ctx.accounts.unwrap_authority { + Some(auth) => auth.to_account_info(), + None => ctx.accounts.swap_global.to_account_info(), + }; + + 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(unwrap_authority), + 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_earner_account: ctx.accounts.from_m_earner.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.intermediate_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(), + }, + &[&[GLOBAL_SEED, &[ctx.accounts.swap_global.bump]]], + ) + .with_remaining_accounts(unwrap_remaining_accounts.to_vec()), + amount, + )?; + + // Reload M balance and wrap difference + ctx.accounts.intermediate_m_account.reload()?; + let m_delta = ctx.accounts.intermediate_m_account.amount - m_pre_balance; + + // Set swap program as authority if none provided + let wrap_authority = match &ctx.accounts.wrap_authority { + Some(auth) => auth.to_account_info(), + None => ctx.accounts.swap_global.to_account_info(), + }; + + m_ext::cpi::wrap( + CpiContext::new_with_signer( + ctx.accounts.to_ext_program.to_account_info(), + Wrap { + token_authority: ctx.accounts.signer.to_account_info(), + wrap_authority: Some(wrap_authority), + m_mint: ctx.accounts.m_mint.to_account_info(), + ext_mint: ctx.accounts.to_mint.to_account_info(), + global_account: ctx.accounts.to_global.to_account_info(), + m_earner_account: ctx.accounts.to_m_earner.to_account_info(), + m_vault: ctx.accounts.to_m_vault_auth.to_account_info(), + ext_mint_authority: ctx.accounts.to_mint_authority.to_account_info(), + from_m_token_account: ctx.accounts.intermediate_m_account.to_account_info(), + vault_m_token_account: ctx.accounts.to_m_vault.to_account_info(), + to_ext_token_account: ctx.accounts.to_token_account.to_account_info(), + m_token_program: ctx.accounts.m_token_program.to_account_info(), + ext_token_program: ctx.accounts.to_token_program.to_account_info(), + }, + &[&[GLOBAL_SEED, &[ctx.accounts.swap_global.bump]]], + ) + .with_remaining_accounts(wrap_remaining_accounts.to_vec()), + m_delta, + )?; + + // Reload and log amounts + ctx.accounts.to_token_account.reload()?; + let received_amount = ctx.accounts.to_token_account.amount - to_pre_balance; + msg!("{} -> {} M -> {}", amount, m_delta, received_amount); + + // Close intermediate account + ctx.accounts.intermediate_m_account.reload()?; + if ctx.accounts.intermediate_m_account.amount == 0 + && ctx.accounts.intermediate_m_account.owner == ctx.accounts.signer.key() + { + anchor_spl::token_interface::close_account(CpiContext::new( + ctx.accounts.m_token_program.to_account_info(), + anchor_spl::token_interface::CloseAccount { + account: ctx.accounts.intermediate_m_account.to_account_info(), + destination: ctx.accounts.signer.to_account_info(), + authority: ctx.accounts.signer.to_account_info(), + }, + ))?; + } + + Ok(()) + } +} diff --git a/programs/ext_swap/src/instructions/unwrap.rs b/programs/ext_swap/src/instructions/unwrap.rs new file mode 100644 index 0000000..49989ff --- /dev/null +++ b/programs/ext_swap/src/instructions/unwrap.rs @@ -0,0 +1,166 @@ +use anchor_lang::prelude::*; +use anchor_spl::associated_token::AssociatedToken; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; +use earn::state::{Earner, EARNER_SEED}; +use m_ext::cpi::accounts::Unwrap as ExtUnwrap; +use m_ext::state::{EXT_GLOBAL_SEED, MINT_AUTHORITY_SEED, M_VAULT_SEED}; + +use crate::errors::SwapError; +use crate::state::{SwapGlobal, GLOBAL_SEED}; + +#[derive(Accounts)] +pub struct Unwrap<'info> { + #[account(mut)] + pub signer: Signer<'info>, + + // Required if the swap program is not whitelisted on the extension + pub unwrap_authority: Option>, + + /* + * Global and Earner accounts + */ + #[account( + has_one = m_mint, + seeds = [GLOBAL_SEED], + bump = swap_global.bump, + )] + pub swap_global: Box>, + #[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>, + #[account( + seeds = [EARNER_SEED, from_m_vault.key().as_ref()], + seeds::program = earn::ID, + bump = m_earner_account.bump, + )] + pub m_earner_account: Box>, + + /* + * Mints + */ + #[account(mut)] + pub from_mint: Box>, + pub m_mint: Box>, + + /* + * Token Accounts + */ + #[account( + init_if_needed, + payer = signer, + associated_token::mint = m_mint, + associated_token::authority = signer, + associated_token::token_program = m_token_program, + )] + pub m_token_account: Box>, + #[account( + mut, + associated_token::mint = from_mint, + associated_token::authority = signer, + associated_token::token_program = from_token_program, + )] + pub from_token_account: Box>, + + /* + * Authorities + */ + #[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>, + + /* + * Vaults + */ + #[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>, + + /* + * Token Programs + */ + pub from_token_program: Interface<'info, TokenInterface>, + pub m_token_program: Interface<'info, TokenInterface>, + + /* + * Programs + */ + /// CHECK: checked against whitelisted extensions + pub from_ext_program: UncheckedAccount<'info>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} + +impl<'info> Unwrap<'info> { + fn validate(&self) -> Result<()> { + if !self + .swap_global + .whitelisted_extensions + .contains(self.from_ext_program.key) + { + return err!(SwapError::InvalidExtension); + } + + if !self + .swap_global + .whitelisted_unwrappers + .contains(self.signer.key) + { + return err!(SwapError::UnauthorizedUnwrapper); + } + + Ok(()) + } + + #[access_control(ctx.accounts.validate())] + pub fn handler(ctx: Context<'_, '_, '_, 'info, Self>, amount: u64) -> Result<()> { + // Set swap program as authority if none provided + let unwrap_authority = match &ctx.accounts.unwrap_authority { + Some(auth) => auth.to_account_info(), + None => ctx.accounts.swap_global.to_account_info(), + }; + + m_ext::cpi::unwrap( + CpiContext::new_with_signer( + ctx.accounts.from_ext_program.to_account_info(), + ExtUnwrap { + token_authority: ctx.accounts.signer.to_account_info(), + unwrap_authority: Some(unwrap_authority), + 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_earner_account: ctx.accounts.m_earner_account.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.m_token_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(), + }, + &[&[GLOBAL_SEED, &[ctx.accounts.swap_global.bump]]], + ) + .with_remaining_accounts(ctx.remaining_accounts.to_vec()), + amount, + ) + } +} diff --git a/programs/ext_swap/src/instructions/whitelist.rs b/programs/ext_swap/src/instructions/whitelist.rs new file mode 100644 index 0000000..38eccc6 --- /dev/null +++ b/programs/ext_swap/src/instructions/whitelist.rs @@ -0,0 +1,166 @@ +use anchor_lang::prelude::*; + +use crate::{ + errors::SwapError, + state::{SwapGlobal, GLOBAL_SEED}, +}; + +#[derive(Accounts)] +pub struct WhitelistExt<'info> { + #[account(mut)] + pub admin: Signer<'info>, + + #[account( + mut, + seeds = [GLOBAL_SEED], + bump = swap_global.bump, + realloc = SwapGlobal::size( + swap_global.whitelisted_unwrappers.len(), + swap_global.whitelisted_extensions.len() + 1, + ), + realloc::payer = admin, + realloc::zero = false, + )] + pub swap_global: Account<'info, SwapGlobal>, + + pub system_program: Program<'info, System>, +} + +impl WhitelistExt<'_> { + fn validate(&self, ext_program: &Pubkey) -> Result<()> { + if self + .swap_global + .whitelisted_extensions + .contains(ext_program) + { + return err!(SwapError::AlreadyWhitelisted); + } + + Ok(()) + } + + #[access_control(ctx.accounts.validate(&ext_program))] + pub fn handler(ctx: Context, ext_program: Pubkey) -> Result<()> { + ctx.accounts + .swap_global + .whitelisted_extensions + .push(ext_program); + + Ok(()) + } +} + +#[derive(Accounts)] +pub struct WhitelistUnwrapper<'info> { + #[account(mut)] + pub admin: Signer<'info>, + + #[account( + mut, + seeds = [GLOBAL_SEED], + bump = swap_global.bump, + realloc = SwapGlobal::size( + swap_global.whitelisted_unwrappers.len() + 1, + swap_global.whitelisted_extensions.len() , + ), + realloc::payer = admin, + realloc::zero = false, + )] + pub swap_global: Account<'info, SwapGlobal>, + + pub system_program: Program<'info, System>, +} + +impl WhitelistUnwrapper<'_> { + fn validate(&self, authority: &Pubkey) -> Result<()> { + if self.swap_global.whitelisted_unwrappers.contains(authority) { + return err!(SwapError::AlreadyWhitelisted); + } + + Ok(()) + } + + #[access_control(ctx.accounts.validate(&authority))] + pub fn handler(ctx: Context, authority: Pubkey) -> Result<()> { + ctx.accounts + .swap_global + .whitelisted_unwrappers + .push(authority); + + Ok(()) + } +} + +#[derive(Accounts)] +pub struct RemoveWhitelistedExt<'info> { + #[account(mut)] + pub admin: Signer<'info>, + + #[account( + mut, + seeds = [GLOBAL_SEED], + bump = swap_global.bump, + )] + pub swap_global: Account<'info, SwapGlobal>, + + pub system_program: Program<'info, System>, +} + +impl RemoveWhitelistedExt<'_> { + fn validate(&self, ext_program: &Pubkey) -> Result<()> { + if !self + .swap_global + .whitelisted_extensions + .contains(ext_program) + { + return err!(SwapError::InvalidExtension); + } + + Ok(()) + } + + #[access_control(ctx.accounts.validate(&ext_program))] + pub fn handler(ctx: Context, ext_program: Pubkey) -> Result<()> { + ctx.accounts + .swap_global + .whitelisted_extensions + .retain(|&x| !x.eq(&ext_program)); + + Ok(()) + } +} + +#[derive(Accounts)] +pub struct RemoveWhitelistedUnwrapper<'info> { + #[account(mut)] + pub admin: Signer<'info>, + + #[account( + mut, + seeds = [GLOBAL_SEED], + bump = swap_global.bump, + )] + pub swap_global: Account<'info, SwapGlobal>, + + pub system_program: Program<'info, System>, +} + +impl RemoveWhitelistedUnwrapper<'_> { + fn validate(&self, authority: &Pubkey) -> Result<()> { + if !self.swap_global.whitelisted_unwrappers.contains(authority) { + return err!(SwapError::UnauthorizedUnwrapper); + } + + Ok(()) + } + + #[access_control(ctx.accounts.validate(&authority))] + pub fn handler(ctx: Context, authority: Pubkey) -> Result<()> { + ctx.accounts + .swap_global + .whitelisted_unwrappers + .retain(|&x| !x.eq(&authority)); + + Ok(()) + } +} diff --git a/programs/ext_swap/src/instructions/wrap.rs b/programs/ext_swap/src/instructions/wrap.rs new file mode 100644 index 0000000..f088a28 --- /dev/null +++ b/programs/ext_swap/src/instructions/wrap.rs @@ -0,0 +1,158 @@ +use anchor_lang::prelude::*; +use anchor_spl::associated_token::AssociatedToken; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; +use earn::state::{Earner, EARNER_SEED}; +use m_ext::cpi::accounts::Wrap as ExtWrap; +use m_ext::state::{EXT_GLOBAL_SEED, MINT_AUTHORITY_SEED, M_VAULT_SEED}; + +use crate::errors::SwapError; +use crate::state::{SwapGlobal, GLOBAL_SEED}; + +#[derive(Accounts)] +pub struct Wrap<'info> { + #[account(mut)] + pub signer: Signer<'info>, + + // Required if the swap program is not whitelisted on the extension + pub wrap_authority: Option>, + + /* + * Program globals + */ + #[account( + has_one = m_mint, + 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>, + #[account( + seeds = [EARNER_SEED, to_m_vault.key().as_ref()], + seeds::program = earn::ID, + bump = m_earner_account.bump, + )] + pub m_earner_account: Box>, + + /* + * Mints + */ + #[account(mut)] + pub to_mint: Box>, + pub m_mint: Box>, + + /* + * Token Accounts + */ + #[account( + mut, + associated_token::mint = m_mint, + associated_token::authority = signer, + associated_token::token_program = m_token_program, + )] + pub m_token_account: Box>, + #[account( + init_if_needed, + payer = signer, + associated_token::mint = to_mint, + associated_token::authority = signer, + associated_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_m_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 = m_mint, + associated_token::authority = to_m_vault_auth, + associated_token::token_program = m_token_program, + )] + pub to_m_vault: Box>, + + /* + * Token Programs + */ + pub to_token_program: Interface<'info, TokenInterface>, + pub m_token_program: Interface<'info, TokenInterface>, + + /* + * Programs + */ + /// CHECK: checked against whitelisted extensions + pub to_ext_program: UncheckedAccount<'info>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} + +impl<'info> Wrap<'info> { + fn validate(&self) -> Result<()> { + if !self + .swap_global + .whitelisted_extensions + .contains(self.to_ext_program.key) + { + return err!(SwapError::InvalidExtension); + } + + Ok(()) + } + + #[access_control(ctx.accounts.validate())] + pub fn handler(ctx: Context<'_, '_, '_, 'info, Self>, amount: u64) -> Result<()> { + // Set swap program as authority if none provided + let wrap_authority = match &ctx.accounts.wrap_authority { + Some(auth) => auth.to_account_info(), + None => ctx.accounts.swap_global.to_account_info(), + }; + + m_ext::cpi::wrap( + CpiContext::new_with_signer( + ctx.accounts.to_ext_program.to_account_info(), + ExtWrap { + token_authority: ctx.accounts.signer.to_account_info(), + wrap_authority: Some(wrap_authority), + m_mint: ctx.accounts.m_mint.to_account_info(), + ext_mint: ctx.accounts.to_mint.to_account_info(), + global_account: ctx.accounts.to_global.to_account_info(), + m_earner_account: ctx.accounts.m_earner_account.to_account_info(), + m_vault: ctx.accounts.to_m_vault_auth.to_account_info(), + ext_mint_authority: ctx.accounts.to_mint_authority.to_account_info(), + from_m_token_account: ctx.accounts.m_token_account.to_account_info(), + vault_m_token_account: ctx.accounts.to_m_vault.to_account_info(), + to_ext_token_account: ctx.accounts.to_token_account.to_account_info(), + m_token_program: ctx.accounts.m_token_program.to_account_info(), + ext_token_program: ctx.accounts.to_token_program.to_account_info(), + }, + &[&[GLOBAL_SEED, &[ctx.accounts.swap_global.bump]]], + ) + .with_remaining_accounts(ctx.remaining_accounts.to_vec()), + amount, + ) + } +} diff --git a/programs/ext_swap/src/lib.rs b/programs/ext_swap/src/lib.rs new file mode 100644 index 0000000..5105984 --- /dev/null +++ b/programs/ext_swap/src/lib.rs @@ -0,0 +1,77 @@ +#![allow(unexpected_cfgs)] + +pub mod errors; +pub mod instructions; +pub mod state; + +use anchor_lang::prelude::*; +use instructions::*; + +#[cfg(not(feature = "no-entrypoint"))] +solana_security_txt::security_txt! { + name: "M0 Swap Program", + project_url: "https://m0.org/", + contacts: "email:security@m0.xyz", + policy: "https://github.com/m0-foundation/solana-m/blob/main/SECURITY.md", + preferred_languages: "en", + source_code: "https://github.com/m0-foundation/solana-extensions/tree/main/programs/ext_swap", + auditors: "" +} + +declare_id!("MSwapi3WhNKMUGm9YrxGhypgUEt7wYQH3ZgG32XoWzH"); + +#[program] +pub mod ext_swap { + use super::*; + + pub fn initialize_global<'info>(ctx: Context, m_mint: Pubkey) -> Result<()> { + InitializeGlobal::handler(ctx, m_mint) + } + + pub fn whitelist_extension<'info>( + ctx: Context, + ext_program: Pubkey, + ) -> Result<()> { + WhitelistExt::handler(ctx, ext_program) + } + + pub fn remove_whitelisted_extension<'info>( + ctx: Context, + ext_program: Pubkey, + ) -> Result<()> { + RemoveWhitelistedExt::handler(ctx, ext_program) + } + + pub fn whitelist_unwrapper<'info>( + ctx: Context, + authority: Pubkey, + ) -> Result<()> { + WhitelistUnwrapper::handler(ctx, authority) + } + + pub fn remove_whitelisted_unwrapper<'info>( + ctx: Context, + authority: Pubkey, + ) -> Result<()> { + RemoveWhitelistedUnwrapper::handler(ctx, authority) + } + + pub fn swap<'info>( + ctx: Context<'_, '_, '_, 'info, Swap<'info>>, + amount: u64, + remaining_accounts_split_idx: u8, + ) -> Result<()> { + 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 unwrap<'info>( + ctx: Context<'_, '_, '_, 'info, Unwrap<'info>>, + amount: u64, + ) -> Result<()> { + Unwrap::handler(ctx, amount) + } +} diff --git a/programs/ext_swap/src/state.rs b/programs/ext_swap/src/state.rs new file mode 100644 index 0000000..f223d4c --- /dev/null +++ b/programs/ext_swap/src/state.rs @@ -0,0 +1,26 @@ +use anchor_lang::prelude::*; + +#[constant] +pub const GLOBAL_SEED: &[u8] = b"global"; + +#[account] +pub struct SwapGlobal { + pub bump: u8, + pub admin: Pubkey, + pub m_mint: Pubkey, + pub whitelisted_unwrappers: Vec, + pub whitelisted_extensions: Vec, +} + +impl SwapGlobal { + pub fn size(unwrappers: usize, extensions: usize) -> usize { + 8 + // discriminator + 1 + // bump + 32 + // admin + 32 + // m_mint + 4 + // length of whitelisted_unwrappers vector + unwrappers * 32 + // each Pubkey is 32 bytes + 4 + // length of whitelisted_extensions vector + extensions * 32 // each Pubkey is 32 bytes + } +} diff --git a/programs/m_ext/Cargo.toml b/programs/m_ext/Cargo.toml new file mode 100644 index 0000000..5f47dc8 --- /dev/null +++ b/programs/m_ext/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "m_ext" +version = "0.1.0" +description = "M extension program with various yield distribution options chosen at compile time" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "m_ext" + +[features] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +cpi = ["no-entrypoint"] +default = ["no-yield"] +idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"] + +# yield features +scaled-ui = [] +no-yield = [] + +[dependencies] +anchor-lang.workspace = true +anchor-spl.workspace = true +spl-token-2022.workspace = true +cfg-if.workspace = true +solana-security-txt.workspace = true +earn.workspace = true diff --git a/programs/m_ext/Xargo.toml b/programs/m_ext/Xargo.toml new file mode 100644 index 0000000..475fb71 --- /dev/null +++ b/programs/m_ext/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] diff --git a/programs/scaled_ui_ext/src/constants.rs b/programs/m_ext/src/constants.rs similarity index 100% rename from programs/scaled_ui_ext/src/constants.rs rename to programs/m_ext/src/constants.rs diff --git a/programs/scaled_ui_ext/src/errors.rs b/programs/m_ext/src/errors.rs similarity index 100% rename from programs/scaled_ui_ext/src/errors.rs rename to programs/m_ext/src/errors.rs diff --git a/programs/scaled_ui_ext/src/instructions/claim_fees.rs b/programs/m_ext/src/instructions/claim_fees.rs similarity index 92% rename from programs/scaled_ui_ext/src/instructions/claim_fees.rs rename to programs/m_ext/src/instructions/claim_fees.rs index afaefb9..b3bff14 100644 --- a/programs/scaled_ui_ext/src/instructions/claim_fees.rs +++ b/programs/m_ext/src/instructions/claim_fees.rs @@ -1,7 +1,10 @@ // external dependencies use anchor_lang::prelude::*; use anchor_spl::token_interface::{Mint, Token2022, TokenAccount}; -use earn::state::Global as EarnGlobal; +use earn::{ + state::{Earner, EARNER_SEED}, + ID as EARN_PROGRAM, +}; // local dependencies use crate::{ @@ -21,14 +24,18 @@ pub struct ClaimFees<'info> { mut, seeds = [EXT_GLOBAL_SEED], has_one = admin @ ExtError::NotAuthorized, - has_one = m_earn_global_account @ ExtError::InvalidAccount, has_one = m_mint @ ExtError::InvalidMint, has_one = ext_mint @ ExtError::InvalidMint, bump = global_account.bump, )] pub global_account: Account<'info, ExtGlobal>, - pub m_earn_global_account: Account<'info, EarnGlobal>, + #[account( + seeds = [EARNER_SEED, vault_m_token_account.key().as_ref()], + seeds::program = EARN_PROGRAM, + bump = m_earner_account.bump, + )] + pub m_earner_account: Account<'info, Earner>, #[account(mint::token_program = m_token_program)] pub m_mint: InterfaceAccount<'info, Mint>, @@ -78,7 +85,7 @@ impl ClaimFees<'_> { let multiplier: f64 = sync_multiplier( &mut ctx.accounts.ext_mint, &mut ctx.accounts.global_account, - &ctx.accounts.m_earn_global_account, + &ctx.accounts.m_earner_account, &ctx.accounts.vault_m_token_account, &ctx.accounts.ext_mint_authority, &[&[MINT_AUTHORITY_SEED, &[signer_bump]]], diff --git a/programs/scaled_ui_ext/src/instructions/initialize.rs b/programs/m_ext/src/instructions/initialize.rs similarity index 56% rename from programs/scaled_ui_ext/src/instructions/initialize.rs rename to programs/m_ext/src/instructions/initialize.rs index d3276ad..55cf74f 100644 --- a/programs/scaled_ui_ext/src/instructions/initialize.rs +++ b/programs/m_ext/src/instructions/initialize.rs @@ -1,28 +1,33 @@ // external dependencies use anchor_lang::prelude::*; -use anchor_spl::{ - associated_token::AssociatedToken, - token_2022_extensions::spl_pod::optional_keys::OptionalNonZeroPubkey, - token_interface::{Mint, Token2022, TokenAccount}, -}; -use spl_token_2022::extension::{ - scaled_ui_amount::ScaledUiAmountConfig, BaseStateWithExtensions, ExtensionType, - StateWithExtensions, +use anchor_spl::token_interface::{Mint, Token2022, TokenAccount}; +use cfg_if::cfg_if; +use earn::{ + state::{Earner, Global as EarnGlobal, EARNER_SEED, GLOBAL_SEED as EARN_GLOBAL_SEED}, + ID as EARN_PROGRAM, }; +use std::collections::HashSet; // local dependencies use crate::{ - constants::{ANCHOR_DISCRIMINATOR_SIZE, INDEX_SCALE_U64, ONE_HUNDRED_PERCENT_U64}, errors::ExtError, - state::{ExtGlobal, EXT_GLOBAL_SEED, MINT_AUTHORITY_SEED, M_VAULT_SEED}, - utils::conversion::sync_multiplier, -}; -use earn::{ - state::{Global as EarnGlobal, GLOBAL_SEED as EARN_GLOBAL_SEED}, - ID as EARN_PROGRAM, + state::{ExtGlobal, YieldConfig, EXT_GLOBAL_SEED, MINT_AUTHORITY_SEED, M_VAULT_SEED}, }; +// conditional dependencies +cfg_if! { + if #[cfg(feature = "scaled-ui")] { + use anchor_spl::token_2022_extensions::spl_pod::optional_keys::OptionalNonZeroPubkey; + use spl_token_2022::extension::ExtensionType; + use crate::{ + constants::{INDEX_SCALE_U64, ONE_HUNDRED_PERCENT_U64}, + utils::conversion::{sync_multiplier, get_mint_extensions, get_scaled_ui_config}, + }; + } +} + #[derive(Accounts)] +#[instruction(wrap_authorities: Vec)] pub struct Initialize<'info> { #[account(mut)] pub admin: Signer<'info>, @@ -30,7 +35,9 @@ pub struct Initialize<'info> { #[account( init, payer = admin, - space = ANCHOR_DISCRIMINATOR_SIZE + ExtGlobal::INIT_SPACE, + space = ExtGlobal::size( + wrap_authorities.len() + ), seeds = [EXT_GLOBAL_SEED], bump )] @@ -78,12 +85,17 @@ pub struct Initialize<'info> { )] pub m_earn_global_account: Account<'info, EarnGlobal>, + #[account( + seeds = [EARNER_SEED, vault_m_token_account.key().as_ref()], + seeds::program = EARN_PROGRAM, + bump = m_earner_account.bump, + )] + pub m_earner_account: Account<'info, Earner>, + pub m_token_program: Program<'info, Token2022>, // we have duplicate entries for the token2022 program bc the M token program could change in the future pub ext_token_program: Program<'info, Token2022>, - pub associated_token_program: Program<'info, AssociatedToken>, - pub system_program: Program<'info, System>, } @@ -94,59 +106,67 @@ impl Initialize<'_> { // The ext_mint must have a supply of 0 to start. // The wrap authorities are validated and stored in the global account. // The fee_bps is validated to be within the allowed range. - - fn validate(&self, wrap_authorities: &[Pubkey], fee_bps: u64) -> Result<()> { + fn validate(&self, _fee_bps: u64) -> Result<()> { // Validate the ext_mint_authority PDA is the mint authority for the ext mint let ext_mint_authority = self.ext_mint_authority.key(); if self.ext_mint.mint_authority.unwrap_or_default() != ext_mint_authority { return err!(ExtError::InvalidMint); } - // Validate that the ext mint has the ScaledUiAmount extension and - // that the ext mint authority is the extension authority - { - // explicit scope to drop the borrow at the end of the code block - let ext_account_info = &self.ext_mint.to_account_info(); - let ext_data = ext_account_info.try_borrow_data()?; - let ext_mint_data = - StateWithExtensions::::unpack(&ext_data)?; - let extensions = ext_mint_data.get_extension_types()?; - - if !extensions.contains(&ExtensionType::ScaledUiAmount) { - return err!(ExtError::InvalidMint); - } - - let scaled_ui_config = ext_mint_data.get_extension::()?; - if scaled_ui_config.authority != OptionalNonZeroPubkey(ext_mint_authority) { - return err!(ExtError::InvalidMint); - } - } - - // Validate the fee_bps is within the allowed range - if fee_bps > ONE_HUNDRED_PERCENT_U64 { - return err!(ExtError::InvalidParam); + // Validate that the ext mint has a freeze authority + if self.ext_mint.freeze_authority.is_none() { + return err!(ExtError::InvalidMint); } - // Validate and create the wrap authorities array - if wrap_authorities.len() > 10 { - return err!(ExtError::InvalidParam); + cfg_if! { + if #[cfg(feature = "scaled-ui")] { + // Validate that the ext mint has the ScaledUiAmount extension and + // that the ext mint authority is the extension authority + let extensions = get_mint_extensions(&self.ext_mint)?; + + if !extensions.contains(&ExtensionType::ScaledUiAmount) { + return err!(ExtError::InvalidMint); + } + + let scaled_ui_config = get_scaled_ui_config(&self.ext_mint)?; + if scaled_ui_config.authority != OptionalNonZeroPubkey(ext_mint_authority) { + return err!(ExtError::InvalidMint); + } + + // Validate the fee_bps is within the allowed range + if _fee_bps > ONE_HUNDRED_PERCENT_U64 { + return err!(ExtError::InvalidParam); + } + } } Ok(()) } - #[access_control(ctx.accounts.validate(&wrap_authorities, fee_bps))] + #[access_control(ctx.accounts.validate(fee_bps))] pub fn handler( ctx: Context, wrap_authorities: Vec, fee_bps: u64, ) -> Result<()> { - let mut wrap_authorities_array = [Pubkey::default(); 10]; - for (i, authority) in wrap_authorities.iter().enumerate() { - if wrap_authorities_array.contains(authority) { - return err!(ExtError::InvalidParam); + // Create hash set from wrap_authorities to ensure uniqueness + let wrap_auth_set: HashSet = wrap_authorities.clone().into_iter().collect(); + if wrap_auth_set.len() < wrap_authorities.len() { + return err!(ExtError::InvalidParam); + } + + // Create the yield config + let yield_config: YieldConfig; + cfg_if! { + if #[cfg(feature = "scaled-ui")] { + yield_config = YieldConfig { + fee_bps, + last_m_index: ctx.accounts.m_earn_global_account.index, + last_ext_index: INDEX_SCALE_U64, // we set the extension index to 1.0 initially + }; + } else { + yield_config = YieldConfig {}; } - wrap_authorities_array[i] = *authority; } // Initialize the ExtGlobal account @@ -155,23 +175,22 @@ impl Initialize<'_> { ext_mint: ctx.accounts.ext_mint.key(), m_mint: ctx.accounts.m_mint.key(), m_earn_global_account: ctx.accounts.m_earn_global_account.key(), - fee_bps, - last_m_index: ctx.accounts.m_earn_global_account.index, - last_ext_index: INDEX_SCALE_U64, // we set the extension index to 1.0 initially bump: ctx.bumps.global_account, m_vault_bump: ctx.bumps.m_vault, ext_mint_authority_bump: ctx.bumps.ext_mint_authority, - wrap_authorities: wrap_authorities_array, + yield_config, + wrap_authorities, }); // Set the ScaledUi multiplier to 1.0 // We can do this by calling the sync_multiplier function // when the last_m_index equals the index on the m_earn_global_account // and having last_ext_index set to 1e12 + #[cfg(feature = "scaled-ui")] sync_multiplier( &mut ctx.accounts.ext_mint, &mut ctx.accounts.global_account, - &ctx.accounts.m_earn_global_account, + &ctx.accounts.m_earner_account, &ctx.accounts.vault_m_token_account, &ctx.accounts.ext_mint_authority, &[&[MINT_AUTHORITY_SEED, &[ctx.bumps.ext_mint_authority]]], diff --git a/programs/m_ext/src/instructions/manage_wrap_authority.rs b/programs/m_ext/src/instructions/manage_wrap_authority.rs new file mode 100644 index 0000000..bbf22c9 --- /dev/null +++ b/programs/m_ext/src/instructions/manage_wrap_authority.rs @@ -0,0 +1,106 @@ +use anchor_lang::prelude::*; + +use crate::{ + errors::ExtError, + state::{ExtGlobal, EXT_GLOBAL_SEED}, +}; + +#[derive(Accounts)] +pub struct AddWrapAuthority<'info> { + #[account(mut)] + pub admin: Signer<'info>, + + #[account( + mut, + seeds = [EXT_GLOBAL_SEED], + has_one = admin @ ExtError::NotAuthorized, + bump = global_account.bump, + realloc = ExtGlobal::size(global_account.wrap_authorities.len() + 1), + realloc::payer = admin, + realloc::zero = false, + )] + pub global_account: Account<'info, ExtGlobal>, + + pub system_program: Program<'info, System>, +} + +impl AddWrapAuthority<'_> { + // This instruction allows the admin to add a wrap authority to the global account. + // The new wrap authority must not already exist in the list. + + pub fn validate(&self, new_wrap_authority: Pubkey) -> Result<()> { + // Validate that the new wrap authority is not already in the list + if self + .global_account + .wrap_authorities + .contains(&new_wrap_authority) + { + return err!(ExtError::InvalidParam); + } + + Ok(()) + } + + #[access_control(ctx.accounts.validate(new_wrap_authority))] + pub fn handler(ctx: Context, new_wrap_authority: Pubkey) -> Result<()> { + // Update the wrap authority at the specified index + ctx.accounts + .global_account + .wrap_authorities + .push(new_wrap_authority); + + Ok(()) + } +} + +#[derive(Accounts)] +pub struct RemoveWrapAuthority<'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, ExtGlobal>, + + pub system_program: Program<'info, System>, +} + +impl RemoveWrapAuthority<'_> { + // This instruction allows the admin to remove a wrap authority from the global account. + // The wrap authority must exist in the list. + + pub fn validate(&self, wrap_authority: Pubkey) -> Result<()> { + // Validate that the wrap authority exists in the list + if !self + .global_account + .wrap_authorities + .contains(&wrap_authority) + { + return err!(ExtError::InvalidParam); + } + + Ok(()) + } + + #[access_control(ctx.accounts.validate(wrap_authority))] + pub fn handler(ctx: Context, wrap_authority: Pubkey) -> Result<()> { + // Remove the specified wrap authority + ctx.accounts + .global_account + .wrap_authorities + .retain(|&x| !x.eq(&wrap_authority)); + + // Reallocate the account to remove the empty space without erasing the other data + let new_size = ExtGlobal::size(ctx.accounts.global_account.wrap_authorities.len()); + ctx.accounts + .global_account + .to_account_info() + .realloc(new_size, false)?; + + Ok(()) + } +} diff --git a/programs/m_ext/src/instructions/mod.rs b/programs/m_ext/src/instructions/mod.rs new file mode 100644 index 0000000..601dfb7 --- /dev/null +++ b/programs/m_ext/src/instructions/mod.rs @@ -0,0 +1,23 @@ +pub mod claim_fees; +pub mod initialize; +pub mod manage_wrap_authority; +pub mod set_m_mint; +pub mod unwrap; +pub mod wrap; + +pub use claim_fees::*; +pub use initialize::*; +pub use manage_wrap_authority::*; +pub use set_m_mint::*; +pub use unwrap::*; +pub use wrap::*; + +cfg_if::cfg_if!( + if #[cfg(feature = "scaled-ui")] { + pub mod set_fee; + pub mod sync; + + pub use set_fee::*; + pub use sync::*; + } +); diff --git a/programs/scaled_ui_ext/src/instructions/set_fee.rs b/programs/m_ext/src/instructions/set_fee.rs similarity index 94% rename from programs/scaled_ui_ext/src/instructions/set_fee.rs rename to programs/m_ext/src/instructions/set_fee.rs index f6368a9..9507a94 100644 --- a/programs/scaled_ui_ext/src/instructions/set_fee.rs +++ b/programs/m_ext/src/instructions/set_fee.rs @@ -27,7 +27,6 @@ impl SetFee<'_> { // If the fee is set to 0, it effectively disables the fee. // If the fee is set to 100, it means the entire amount is taken as a fee. // Any value above 100 bps will result in an error. - fn validate(&self, fee_bps: u64) -> Result<()> { // Validate that the fee is between 0 and 100 bps if fee_bps > ONE_HUNDRED_PERCENT_U64 { @@ -39,7 +38,7 @@ impl SetFee<'_> { #[access_control(ctx.accounts.validate(fee_bps))] pub fn handler(ctx: Context, fee_bps: u64) -> Result<()> { // Set the new fee - ctx.accounts.global_account.fee_bps = fee_bps; + ctx.accounts.global_account.yield_config.fee_bps = fee_bps; Ok(()) } diff --git a/programs/scaled_ui_ext/src/instructions/set_m_mint.rs b/programs/m_ext/src/instructions/set_m_mint.rs similarity index 100% rename from programs/scaled_ui_ext/src/instructions/set_m_mint.rs rename to programs/m_ext/src/instructions/set_m_mint.rs diff --git a/programs/scaled_ui_ext/src/instructions/sync.rs b/programs/m_ext/src/instructions/sync.rs similarity index 85% rename from programs/scaled_ui_ext/src/instructions/sync.rs rename to programs/m_ext/src/instructions/sync.rs index 5497e1a..928d6d2 100644 --- a/programs/scaled_ui_ext/src/instructions/sync.rs +++ b/programs/m_ext/src/instructions/sync.rs @@ -5,19 +5,17 @@ use crate::{ }; use anchor_lang::prelude::*; use anchor_spl::token_interface::{Mint, Token2022, TokenAccount}; -use earn::state::Global as EarnGlobal; +use earn::{ + state::{Earner, EARNER_SEED}, + ID as EARN_PROGRAM, +}; #[derive(Accounts)] pub struct Sync<'info> { - pub signer: Signer<'info>, - - pub m_earn_global_account: Account<'info, EarnGlobal>, - #[account( mut, seeds = [EXT_GLOBAL_SEED], bump = global_account.bump, - has_one = m_earn_global_account @ ExtError::InvalidAccount, has_one = ext_mint @ ExtError::InvalidMint, )] pub global_account: Account<'info, ExtGlobal>, @@ -36,6 +34,13 @@ pub struct Sync<'info> { )] pub vault_m_token_account: InterfaceAccount<'info, TokenAccount>, + #[account( + seeds = [EARNER_SEED, vault_m_token_account.key().as_ref()], + seeds::program = EARN_PROGRAM, + bump = m_earner_account.bump, + )] + pub m_earner_account: Account<'info, Earner>, + #[account(mut)] pub ext_mint: InterfaceAccount<'info, Mint>, @@ -59,7 +64,7 @@ impl Sync<'_> { sync_multiplier( &mut ctx.accounts.ext_mint, &mut ctx.accounts.global_account, - &ctx.accounts.m_earn_global_account, + &ctx.accounts.m_earner_account, &ctx.accounts.vault_m_token_account, &ctx.accounts.ext_mint_authority, &[&[MINT_AUTHORITY_SEED, &[signer_bump]]], diff --git a/programs/scaled_ui_ext/src/instructions/unwrap.rs b/programs/m_ext/src/instructions/unwrap.rs similarity index 73% rename from programs/scaled_ui_ext/src/instructions/unwrap.rs rename to programs/m_ext/src/instructions/unwrap.rs index c3f47e9..029a576 100644 --- a/programs/scaled_ui_ext/src/instructions/unwrap.rs +++ b/programs/m_ext/src/instructions/unwrap.rs @@ -5,15 +5,21 @@ use crate::{ errors::ExtError, state::{ExtGlobal, EXT_GLOBAL_SEED, MINT_AUTHORITY_SEED, M_VAULT_SEED}, utils::{ - conversion::{amount_to_principal_up, sync_multiplier}, + conversion::{amount_to_principal_up, principal_to_amount_down, sync_multiplier}, token::{burn_tokens, transfer_tokens_from_program}, }, }; -use earn::state::Global as EarnGlobal; +use earn::{ + state::{Earner, EARNER_SEED}, + ID as EARN_PROGRAM, +}; #[derive(Accounts)] pub struct Unwrap<'info> { - pub signer: Signer<'info>, + pub token_authority: Signer<'info>, + + // Will be set if a whitelisted authority is signing for a user + pub unwrap_authority: Option>, #[account(mint::token_program = m_token_program)] pub m_mint: InterfaceAccount<'info, Mint>, @@ -27,11 +33,15 @@ pub struct Unwrap<'info> { bump = global_account.bump, has_one = m_mint @ ExtError::InvalidAccount, has_one = ext_mint @ ExtError::InvalidAccount, - has_one = m_earn_global_account @ ExtError::InvalidAccount, )] pub global_account: Account<'info, ExtGlobal>, - pub m_earn_global_account: Account<'info, EarnGlobal>, + #[account( + seeds = [EARNER_SEED, vault_m_token_account.key().as_ref()], + seeds::program = EARN_PROGRAM, + bump = m_earner_account.bump, + )] + pub m_earner_account: Account<'info, Earner>, /// CHECK: This account is validated by the seed, it stores no data #[account( @@ -80,13 +90,13 @@ pub struct Unwrap<'info> { impl Unwrap<'_> { pub fn validate(&self) -> Result<()> { - // Ensure the signer is authorized to unwrap - if self.signer.key() == Pubkey::default() || // probably don't need to check this, but it's included for completeness - !self - .global_account - .wrap_authorities - .contains(&self.signer.key()) - { + let auth = match &self.unwrap_authority { + Some(auth) => auth.key, + None => self.token_authority.key, + }; + + // Ensure the caller is authorized to wrap + if !self.global_account.wrap_authorities.contains(auth) { return err!(ExtError::NotAuthorized); } @@ -94,19 +104,18 @@ impl Unwrap<'_> { } #[access_control(ctx.accounts.validate())] - pub fn handler(ctx: Context, amount: u64) -> Result<()> { + pub fn handler(ctx: Context, mut amount: u64) -> Result<()> { let authority_seeds: &[&[&[u8]]] = &[&[ MINT_AUTHORITY_SEED, &[ctx.accounts.global_account.ext_mint_authority_bump], ]]; - // Update the scaled UI multiplier with the current M index - // before unwrapping tokens - // If multiplier up to date, just reads the current value + // If necessary, sync the multiplier between M and Ext tokens + // Return the current value to use for conversions let multiplier = sync_multiplier( &mut ctx.accounts.ext_mint, &mut ctx.accounts.global_account, - &ctx.accounts.m_earn_global_account, + &ctx.accounts.m_earner_account, &ctx.accounts.vault_m_token_account, &ctx.accounts.ext_mint_authority, authority_seeds, @@ -118,15 +127,16 @@ impl Unwrap<'_> { let mut principal = amount_to_principal_up(amount, multiplier)?; if principal > ctx.accounts.from_ext_token_account.amount { principal = ctx.accounts.from_ext_token_account.amount; + amount = principal_to_amount_down(principal, multiplier)?; } // Burn the amount of ext tokens from the user burn_tokens( - &ctx.accounts.from_ext_token_account, // from - principal, // amount - &ctx.accounts.ext_mint, // mint - &ctx.accounts.signer.to_account_info(), // authority - &ctx.accounts.ext_token_program, // token program + &ctx.accounts.from_ext_token_account, // from + principal, // amount + &ctx.accounts.ext_mint, // mint + &ctx.accounts.token_authority.to_account_info(), // authority + &ctx.accounts.ext_token_program, // token program )?; // Transfer the amount of m tokens from the m vault to the user diff --git a/programs/scaled_ui_ext/src/instructions/wrap.rs b/programs/m_ext/src/instructions/wrap.rs similarity index 74% rename from programs/scaled_ui_ext/src/instructions/wrap.rs rename to programs/m_ext/src/instructions/wrap.rs index e17cd57..8dfa80a 100644 --- a/programs/scaled_ui_ext/src/instructions/wrap.rs +++ b/programs/m_ext/src/instructions/wrap.rs @@ -9,11 +9,17 @@ use crate::{ token::{mint_tokens, transfer_tokens}, }, }; -use earn::state::Global as EarnGlobal; +use earn::{ + state::{Earner, EARNER_SEED}, + ID as EARN_PROGRAM, +}; #[derive(Accounts)] pub struct Wrap<'info> { - pub signer: Signer<'info>, + pub token_authority: Signer<'info>, + + // Will be set if a whitelisted authority is signing for a user + pub wrap_authority: Option>, #[account(mint::token_program = m_token_program)] pub m_mint: InterfaceAccount<'info, Mint>, @@ -27,11 +33,15 @@ pub struct Wrap<'info> { bump = global_account.bump, has_one = m_mint @ ExtError::InvalidAccount, has_one = ext_mint @ ExtError::InvalidAccount, - has_one = m_earn_global_account @ ExtError::InvalidAccount, )] pub global_account: Account<'info, ExtGlobal>, - pub m_earn_global_account: Account<'info, EarnGlobal>, + #[account( + seeds = [EARNER_SEED, vault_m_token_account.key().as_ref()], + seeds::program = EARN_PROGRAM, + bump = m_earner_account.bump, + )] + pub m_earner_account: Account<'info, Earner>, /// CHECK: This account is validated by the seed, it stores no data #[account( @@ -80,13 +90,13 @@ pub struct Wrap<'info> { impl Wrap<'_> { pub fn validate(&self) -> Result<()> { - // Ensure the signer is authorized to wrap - if self.signer.key() == Pubkey::default() || // probably don't need to check this, but it's included for completeness - !self - .global_account - .wrap_authorities - .contains(&self.signer.key()) - { + let auth = match &self.wrap_authority { + Some(auth) => auth.key, + None => self.token_authority.key, + }; + + // Ensure the caller is authorized to wrap + if !self.global_account.wrap_authorities.contains(auth) { return err!(ExtError::NotAuthorized); } @@ -100,13 +110,12 @@ impl Wrap<'_> { &[ctx.accounts.global_account.ext_mint_authority_bump], ]]; - // Update the scaled UI multiplier with the current M index - // before wrapping new tokens - // If multiplier up to date, just reads the current value + // If necessary, sync the multiplier between M and Ext tokens + // Return the current value to use for conversions let multiplier = sync_multiplier( &mut ctx.accounts.ext_mint, &mut ctx.accounts.global_account, - &ctx.accounts.m_earn_global_account, + &ctx.accounts.m_earner_account, &ctx.accounts.vault_m_token_account, &ctx.accounts.ext_mint_authority, authority_seeds, @@ -115,16 +124,17 @@ impl Wrap<'_> { // Transfer the amount of m tokens from the user to the m vault transfer_tokens( - &ctx.accounts.from_m_token_account, // from - &ctx.accounts.vault_m_token_account, // to - amount, // amount - &ctx.accounts.m_mint, // mint - &ctx.accounts.signer.to_account_info(), // authority - &ctx.accounts.m_token_program, // token program + &ctx.accounts.from_m_token_account, // from + &ctx.accounts.vault_m_token_account, // to + amount, // amount + &ctx.accounts.m_mint, // mint + &ctx.accounts.token_authority.to_account_info(), // authority + &ctx.accounts.m_token_program, // token program )?; // Calculate the amount of ext tokens to mint based // on the amount of m tokens wrapped + // If multiplier is 1.0, the amount remains the same let principal = amount_to_principal_down(amount, multiplier)?; // Mint the amount of ext tokens to the user diff --git a/programs/scaled_ui_ext/src/lib.rs b/programs/m_ext/src/lib.rs similarity index 61% rename from programs/scaled_ui_ext/src/lib.rs rename to programs/m_ext/src/lib.rs index 668a8a5..cdb73c3 100644 --- a/programs/scaled_ui_ext/src/lib.rs +++ b/programs/m_ext/src/lib.rs @@ -1,4 +1,4 @@ -// top-level program file +#![allow(unexpected_cfgs)] pub mod constants; pub mod errors; @@ -25,12 +25,24 @@ solana_security_txt::security_txt! { declare_id!("3C865D264L4NkAm78zfnDzQJJvXuU3fMjRUvRxyPi5da"); +// Validate feature combinations +const _: () = { + let yield_features = { cfg!(feature = "scaled-ui") as u32 + cfg!(feature = "no-yield") as u32 }; + + match yield_features { + 0 => panic!("No yield distribution feature enabled"), + 1 => {} + 2.. => panic!("Only one yield distribution feature can be enabled at a time"), + } +}; + #[program] -pub mod scaled_ui_ext { +pub mod m_ext { use super::*; // Admin instructions + #[cfg(feature = "scaled-ui")] pub fn initialize( ctx: Context, wrap_authorities: Vec, @@ -39,6 +51,12 @@ pub mod scaled_ui_ext { Initialize::handler(ctx, wrap_authorities, fee_bps) } + #[cfg(feature = "no-yield")] + pub fn initialize(ctx: Context, wrap_authorities: Vec) -> Result<()> { + Initialize::handler(ctx, wrap_authorities, 0) + } + + #[cfg(feature = "scaled-ui")] pub fn set_fee(ctx: Context, fee_bps: u64) -> Result<()> { SetFee::handler(ctx, fee_bps) } @@ -47,12 +65,18 @@ pub mod scaled_ui_ext { SetMMint::handler(ctx) } - pub fn update_wrap_authority( - ctx: Context, - index: u8, + pub fn add_wrap_authority( + ctx: Context, new_wrap_authority: Pubkey, ) -> Result<()> { - UpdateWrapAuthority::handler(ctx, index, new_wrap_authority) + AddWrapAuthority::handler(ctx, new_wrap_authority) + } + + pub fn remove_wrap_authority( + ctx: Context, + wrap_authority: Pubkey, + ) -> Result<()> { + RemoveWrapAuthority::handler(ctx, wrap_authority) } pub fn claim_fees(ctx: Context) -> Result<()> { @@ -71,6 +95,7 @@ pub mod scaled_ui_ext { // Open instructions + #[cfg(feature = "scaled-ui")] pub fn sync(ctx: Context) -> Result<()> { Sync::handler(ctx) } diff --git a/programs/m_ext/src/state.rs b/programs/m_ext/src/state.rs new file mode 100644 index 0000000..6056870 --- /dev/null +++ b/programs/m_ext/src/state.rs @@ -0,0 +1,68 @@ +use anchor_lang::prelude::*; +use cfg_if::cfg_if; + +#[constant] +pub const EXT_GLOBAL_SEED: &[u8] = b"global"; + +#[account] +pub struct ExtGlobal { + pub admin: Pubkey, // can update config values + pub ext_mint: Pubkey, + pub m_mint: Pubkey, + pub m_earn_global_account: Pubkey, + 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 +} + +impl ExtGlobal { + pub fn size(wrap_authorities: usize) -> usize { + 8 + // discriminator + 32 + // admin + 32 + // ext_mint + 32 + // m_mint + 32 + // m_earn_global_account + 1 + // bump + 1 + // m_vault_bump + 1 + // ext_mint_authority_bump + YieldConfig::space() + // yield_config + 4 + // length of wrap_authorities vector + wrap_authorities * 32 // each Pubkey is 32 bytes + } +} + +#[constant] +pub const MINT_AUTHORITY_SEED: &[u8] = b"mint_authority"; + +#[constant] +pub const M_VAULT_SEED: &[u8] = b"m_vault"; + +cfg_if! { + if #[cfg(feature = "scaled-ui")] { + #[derive(AnchorSerialize, AnchorDeserialize, Clone)] + pub struct YieldConfig { + pub fee_bps: u64, // fee in basis points + pub last_m_index: u64, // last m index + pub last_ext_index: u64, // last ext index + } + + impl YieldConfig { + pub fn space() -> usize { + 8 + // fee_bps + 8 + // last_m_index + 8 // last_ext_index + } + } + } else { + #[derive(AnchorSerialize, AnchorDeserialize, Clone)] + pub struct YieldConfig {} + + impl YieldConfig { + pub fn space() -> usize { + 0 // no space needed for yield config in no-yield mode + } + } + } +} diff --git a/programs/m_ext/src/utils/conversion.rs b/programs/m_ext/src/utils/conversion.rs new file mode 100644 index 0000000..1b9c7d1 --- /dev/null +++ b/programs/m_ext/src/utils/conversion.rs @@ -0,0 +1,519 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{Mint, Token2022, TokenAccount}; +use cfg_if::cfg_if; +use earn::state::Earner; +use spl_token_2022::extension::{BaseStateWithExtensions, StateWithExtensions}; + +use crate::{ + constants::{INDEX_SCALE_F64, INDEX_SCALE_U64}, + errors::ExtError, + state::ExtGlobal, +}; + +cfg_if! { + if #[cfg(feature = "scaled-ui")] { + use anchor_lang::solana_program::program::invoke_signed; + use spl_token_2022::extension::scaled_ui_amount::{PodF64, ScaledUiAmountConfig, UnixTimestamp}; + use crate::constants::ONE_HUNDRED_PERCENT_F64; + } +} + +#[allow(unused_variables)] +pub fn sync_multiplier<'info>( + ext_mint: &mut InterfaceAccount<'info, Mint>, + ext_global_account: &mut Account<'info, ExtGlobal>, + m_earner_account: &Account<'info, Earner>, + vault_m_token_account: &InterfaceAccount<'info, TokenAccount>, + authority: &AccountInfo<'info>, + authority_seeds: &[&[&[u8]]], + token_program: &Program<'info, Token2022>, +) -> Result { + cfg_if! { + if #[cfg(feature = "scaled-ui")] { + // Get the current index and timestamp from the m_earn_global_account and cached values + let (multiplier, timestamp): (f64, i64) = + get_latest_multiplier_and_timestamp(ext_global_account, m_earner_account); + + // Compare against the current multiplier + // If the multiplier is the same, we don't need to update + let scaled_ui_config = get_scaled_ui_config(ext_mint)?; + + if scaled_ui_config.new_multiplier == PodF64::from(multiplier) + && scaled_ui_config.new_multiplier_effective_timestamp == UnixTimestamp::from(timestamp) + { + return Ok(multiplier); + } + + // Update the multiplier and timestamp in the mint account + invoke_signed( + &spl_token_2022::extension::scaled_ui_amount::instruction::update_multiplier( + &token_program.key(), + &ext_mint.key(), + &authority.key(), + &[], + multiplier, + timestamp, + )?, + &[ext_mint.to_account_info(), authority.clone()], + authority_seeds, + )?; + + // Reload the mint account so the new multiplier is reflected + ext_mint.reload()?; + + // Update the last m index and last ext index in the global account + ext_global_account.yield_config.last_m_index = m_earner_account.last_claim_index; + ext_global_account.yield_config.last_ext_index = (multiplier * INDEX_SCALE_F64).floor() as u64; + + // Note: This check should not be required anymore because we are using the vault's last claim index + // however, we keep it here for now to continue testing + // + // Check solvency of the vault + // i.e. that it holds enough M for each extension UI amount + // after the multiplier has been updated + if ext_mint.supply > 0 { + // Calculate the amount of tokens in the vault + let vault_m = vault_m_token_account.amount; + + // Calculate the amount of tokens needed to be solvent + // Reduce it by two to avoid rounding errors (there is an edge cases where the rounding error + // from one index (down) to the next (up) can cause the difference to be 2) + let mut required_m = principal_to_amount_down(ext_mint.supply, multiplier)?; + required_m -= std::cmp::min(2, required_m); + + // Check if the vault has enough tokens + if vault_m < required_m { + return err!(ExtError::InsufficientCollateral); + } + } + + return Ok(multiplier); + } else { + // Ext tokens are 1:1 with M tokens and we don't need to sync this + return Ok(1.0); + } + } +} + +pub fn amount_to_principal_down(amount: u64, multiplier: f64) -> Result { + // If the multiplier is 1, return the amount directly + if multiplier == 1.0 { + return Ok(amount); + } + + // We want to avoid precision errors with floating point numbers + // Therefore, we use integer math. + let index = (multiplier * INDEX_SCALE_F64).trunc() as u128; + + // Calculate the principal from the amount and index, rounding down + let principal: u64 = (amount as u128) + .checked_mul(INDEX_SCALE_U64 as u128) + .ok_or(ExtError::MathOverflow)? + .checked_div(index) + .ok_or(ExtError::MathUnderflow)? + .try_into()?; + + Ok(principal) +} + +pub fn amount_to_principal_up(amount: u64, multiplier: f64) -> Result { + // If the multiplier is 1, return the amount directly + if multiplier == 1.0 { + return Ok(amount); + } + + // We want to avoid precision errors with floating point numbers + // Therefore, we use integer math. + let index = (multiplier * INDEX_SCALE_F64).trunc() as u128; + + // Calculate the principal from the amount and index, rounding up + let principal: u64 = (amount as u128) + .checked_mul(INDEX_SCALE_U64 as u128) + .ok_or(ExtError::MathOverflow)? + .checked_add(index.checked_sub(1u128).ok_or(ExtError::MathUnderflow)?) + .ok_or(ExtError::MathOverflow)? + .checked_div(index) + .ok_or(ExtError::MathUnderflow)? + .try_into()?; + + Ok(principal) +} + +pub fn principal_to_amount_down(principal: u64, multiplier: f64) -> Result { + // If the multiplier is 1, return the principal directly + if multiplier == 1.0 { + return Ok(principal); + } + + // We want to avoid precision errors with floating point numbers + // Therefore, we use integer math. + let index = (multiplier * INDEX_SCALE_F64).trunc() as u128; + + // Calculate the amount from the principal and index, rounding down + let amount: u64 = index + .checked_mul(principal as u128) + .ok_or(ExtError::MathOverflow)? + .checked_div(INDEX_SCALE_U64 as u128) + .ok_or(ExtError::MathUnderflow)? + .try_into()?; + + Ok(amount) +} + +pub fn principal_to_amount_up(principal: u64, multiplier: f64) -> Result { + // If the multiplier is 1, return the principal directly + if multiplier == 1.0 { + return Ok(principal); + } + + // We want to avoid precision errors with floating point numbers + // Therefore, we use integer math. + let index = (multiplier * INDEX_SCALE_F64).trunc() as u128; + + // Calculate the amount from the principal and index, rounding up + let amount: u64 = index + .checked_mul(principal as u128) + .ok_or(ExtError::MathOverflow)? + .checked_add( + (INDEX_SCALE_U64 as u128) + .checked_sub(1u128) + .ok_or(ExtError::MathUnderflow)?, + ) + .ok_or(ExtError::MathOverflow)? + .checked_div(INDEX_SCALE_U64 as u128) + .ok_or(ExtError::MathUnderflow)? + .try_into()?; + + Ok(amount) +} + +pub fn get_mint_extensions<'info>( + mint: &InterfaceAccount<'info, Mint>, +) -> Result> { + // Get the mint account data + let account_info = mint.to_account_info(); + let mint_data = account_info.try_borrow_data()?; + let mint_ext_data = StateWithExtensions::::unpack(&mint_data)?; + + let extensions = mint_ext_data.get_extension_types()?; + + Ok(extensions) +} + +cfg_if! { + if #[cfg(feature = "scaled-ui")] { + pub fn get_scaled_ui_config<'info>( + mint: &InterfaceAccount<'info, Mint>, + ) -> Result { + // Get the mint account data with extensions + let account_info = mint.to_account_info(); + let mint_data = account_info.try_borrow_data()?; + let mint_ext_data = StateWithExtensions::::unpack(&mint_data)?; + + // Get the scaled UI config extension + let scaled_ui_config = mint_ext_data.get_extension::()?; + + Ok(*scaled_ui_config) + } + + + fn get_latest_multiplier_and_timestamp<'info>( + ext_global_account: &Account<'info, ExtGlobal>, + m_earner_account: &Account<'info, Earner>, + ) -> (f64, i64) { + let latest_m_multiplier = m_earner_account.last_claim_index as f64 / INDEX_SCALE_F64; + let cached_m_multiplier = ext_global_account.yield_config.last_m_index as f64 / INDEX_SCALE_F64; + let latest_timestamp: i64 = m_earner_account.last_claim_timestamp as i64; + let cached_ext_multiplier = + ext_global_account.yield_config.last_ext_index as f64 / INDEX_SCALE_F64; + + // If no change, return early + if latest_m_multiplier == cached_m_multiplier { + return (cached_ext_multiplier, latest_timestamp); + } + + // Calculate the new ext multiplier based on the latest m multiplier and timestamp + let new_ext_multiplier = calculate_new_multiplier( + cached_ext_multiplier, + cached_m_multiplier, + latest_m_multiplier, + ext_global_account.yield_config.fee_bps + ); + + (new_ext_multiplier, latest_timestamp) + } + + fn calculate_new_multiplier( + last_ext_multiplier: f64, + last_m_multiplier: f64, + new_m_multiplier: f64, + fee_bps: u64, + ) -> f64 { + // Calculate the new ext multiplier from the formula: + // new_ext_multiplier = last_ext_multiplier * (new_m_multiplier / last_m_multiplier) ^ (1 - fee_on_yield) + // The derivation of this formula is explained in this document: https://gist.github.com/Oighty/89dd1288a0a7fb53eb6f0314846cb746 + let m_increase_factor = new_m_multiplier / last_m_multiplier; + + // Calculate the increase factor for the ext index, if the fee is zero, then the increase factor is the same as M + let ext_increase_factor = if fee_bps == 0 { + m_increase_factor + } else { + // Calculate the increase factor for the ext index + let fee_on_yield = fee_bps as f64 / ONE_HUNDRED_PERCENT_F64; + // The precision of the powf operation is non-deterministic + // However, the margin of error is ~10^-16, which is smaller than the 10^-12 precision + // that we need for this use case. See: https://doc.rust-lang.org/std/primitive.f64.html#method.powf + m_increase_factor.powf(1.0f64 - fee_on_yield) + }; + + // Calculate the new extension multiplier (index in f64 scaled down) + let new_ext_multiplier = last_ext_multiplier * ext_increase_factor; + + // We need to round the new multiplier down and truncate at 10^-12 + // to return a consistent value + (new_ext_multiplier * INDEX_SCALE_F64).floor() / INDEX_SCALE_F64 + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + cfg_if! { + if #[cfg(feature = "scaled-ui")] { + #[test] + fn test_calculate_new_multiplier_no_fee() { + // cases (starting from 1.0): + // no rounding + let expected = 1.125000000000; + let result = calculate_new_multiplier(1.0, 1.0, 1.125, 0); + assert_eq!(result, expected); + + // would round up -> truncates + let expected = 1.666666666666; + let result = calculate_new_multiplier(1.0, 1.5, 2.5, 0); + assert_eq!(result, expected); + + // would round down -> truncates + let expected = 1.333333333333; + let result = calculate_new_multiplier(1.0, 1.5, 2.0, 0); + assert_eq!(result, expected); + + // cases (starting from truncated value that would have rounded up): + // no rounding + let expected = 1.749999999999; // off by one due to previous rounding + let result = calculate_new_multiplier(1.666666666666, 2.0, 2.1, 0); + assert_eq!(result, expected); + + // would round up -> truncates + let expected = 1.777777777777; + let result = calculate_new_multiplier(1.666666666666, 3.0, 3.2, 0); + assert_eq!(result, expected); + + // would round down -> truncates + let expected = 2.333333333332; // off by one due to previous rounding + let result = calculate_new_multiplier(1.666666666666, 5.0, 7.0, 0); + assert_eq!(result, expected); + + // cases (starting from truncated value that would have rounded down) + let expected = 1.499999999999; // off by one due to previous rounding + let result = calculate_new_multiplier(1.333333333333, 2.0, 2.25, 0); + assert_eq!(result, expected); + + // would round up -> truncates + let expected = 1.666666666666; + let result = calculate_new_multiplier(1.333333333333, 2.0, 2.5, 0); + assert_eq!(result, expected); + + // would round down -> truncates + let expected = 2.333333333332; + let result = calculate_new_multiplier(1.333333333333, 4.0, 7.0, 0); + assert_eq!(result, expected); + } + + // Helper function to trim the value to 12 decimal places after subtracting expected rounding error + // This is needed to deal with imprecision in floating point arithmetic + fn trim(value: f64) -> f64 { + // Truncate the value to 12 decimal places + (value * INDEX_SCALE_F64).ceil() / INDEX_SCALE_F64 + } + + #[test] + fn test_calculate_new_multiplier_with_fee() { + // there are three calculations here to test rounding behavior: + // 1. m_increase_factor = new_m_multiplier / last_m_multiplier + // 2. ext_increase_factor = m_increase_factor.powf(1.0 - fee_on_yield) + // 3. new_ext_multiplier = last_ext_multiplier * ext_increase_factor + // cases are listed with what the rounding behavior would be for each calculation + // even though the rounding only happens when converting back to u64 for the final result + // the basic expectation is that if there is a roundup anywhere in the sequence + // the final result will be off by one to the downside due to truncation + + // cases: + // Note: we can't reliably get examples that wouldn't round either direction for the 2nd equation since it is a fractional exponent + // A + // 1. no rounding + // 2. rounds down + // 3. no rounding + let result = calculate_new_multiplier(1.0, 1.0, 1.125, 2500); + let expected_actual = 1.092356486341; // wolfram alpha: 1.092356486341477... + let expected = expected_actual; // no error + assert_eq!(result, expected); + + // B + // 1. no rounding + // 2. rounds down + // 3. rounds down + let result = calculate_new_multiplier(1.3, 1.5, 1.65, 1500); + let expected_actual = 1.409701411824; // wolfram alpha: 1.409701411824313... + let expected = expected_actual; // no error + assert_eq!(result, expected); + + // C + // 1. no rounding + // 2. rounds down + // 3. would round up -> truncates + let result = calculate_new_multiplier(1.2, 1.5, 1.65, 1500); + let expected_actual = 1.301262841684; // wolfram alpha: 1.301262841683981... + let expected = trim(expected_actual - 0.000000000001); // off by one due to truncation + assert_eq!(result, expected); + + // D + // 1. no rounding + // 2. would round up -> truncates + // 3. no rounding + let result = calculate_new_multiplier(1.0, 1.5, 1.65, 1000); + let expected_actual = 1.089565684036; // wolfram alpha: 1.089565684035973... + let expected = trim(expected_actual - 0.000000000001); // off by one due to truncation + assert_eq!(result, expected); + + // E + // 1. no rounding + // 2. would round up -> truncates + // 3. rounds down + let result = calculate_new_multiplier(1.2, 1.5, 1.65, 1000); + let expected_actual = 1.307478820843; // wolfram alpha: 1.307478820843168... + let expected = expected_actual; // no error + assert_eq!(result, expected); + + // F + // 1. no rounding + // 2. would round up -> truncates + // 3. would round up -> truncates + let result = calculate_new_multiplier(1.3, 1.5, 1.65, 1000); + let expected_actual = 1.416435389247; // wolfram alpha: 1.41643538924676614906538927073063715743660444837662580163175093387867947... + let expected = trim(expected_actual - 0.000000000001); // off by one due to truncation + assert_eq!(result, expected); + + // G + // 1. rounds down + // 2. rounds down + // 3. no rounding + let result = calculate_new_multiplier(1.0, 1.125, 1.25, 1000); + let expected_actual = 1.099465842451; // wolfram alpha: 1.099465842451349... + let expected = expected_actual; // no error + assert_eq!(result, expected); + + // H + // 1. rounds down + // 2. rounds down + // 3. rounds down + let result = calculate_new_multiplier(1.1, 1.125, 1.25, 1000); + let expected_actual = 1.209412426696; // wolfram alpha: 1.209412426696484... + let expected = expected_actual; // no error + assert_eq!(result, expected); + + // I + // 1. rounds down + // 2. rounds down + // 3. would round up -> truncates + let result = calculate_new_multiplier(1.2, 1.125, 1.25, 1000); + let expected_actual = 1.319359010942; // wolfram alpha: 1.319359010941619... + let expected = trim(expected_actual - 0.000000000001); // off by one due to truncation + assert_eq!(result, expected); + + // J + // 1. rounds down + // 2. would round up -> truncates + // 3. no rounding + let result = calculate_new_multiplier(1.0, 1.125, 1.25, 2000); + let expected_actual = 1.087942624846; // wolfram alpha: 1.087942624845529... + let expected = trim(expected_actual - 0.000000000001); // off by one due to truncation + assert_eq!(result, expected); + + // K + // 1. rounds down + // 2. would round up -> truncates + // 3. rounds down + let result = calculate_new_multiplier(1.3, 1.125, 1.25, 2000); + let expected_actual = 1.414325412299; // wolfram alpha: 1.414325412299188... + let expected = expected_actual; // no error + assert_eq!(result, expected); + + // L + // 1. rounds down + // 2. would round up -> truncates + // 3. would round up -> truncates + let result = calculate_new_multiplier(1.2, 1.125, 1.25, 2000); + let expected_actual = 1.305531149815; // wolfram alpha: 1.305531149814635... + let expected = trim(expected_actual - 0.000000000001); // off by one due to truncation + assert_eq!(result, expected); + + // M + // 1. would round up -> truncates + // 2. rounds down + // 3. no rounding + let result = calculate_new_multiplier(1.0, 3.0, 3.2, 1000); + let expected_actual = 1.059804724543; // wolfram alpha: 1.059804724543068... + let expected = expected_actual; // no error + assert_eq!(result, expected); + + // N + // 1. would round up -> truncates + // 2. rounds down + // 3. rounds down + let result = calculate_new_multiplier(1.4, 3.0, 3.2, 1000); + let expected_actual = 1.483726614360; // wolfram alpha: 1.483726614360295... + let expected = expected_actual; // no error + assert_eq!(result, expected); + + // O + // 1. would round up -> truncates + // 2. rounds down + // 3. would round up -> truncates + let result = calculate_new_multiplier(1.2, 3.0, 3.2, 1000); + let expected_actual = 1.271765669452; // wolfram alpha: 1.271765669451681... + let expected = trim(expected_actual - 0.000000000001); // off by one due to truncation + assert_eq!(result, expected); + + // P + // 1. would round up -> truncates + // 2. would round up -> truncates + // 3. no rounding + let result = calculate_new_multiplier(1.0, 3.0, 3.2, 2000); + let expected_actual = 1.052986925779; // wolfram alpha: 1.052986925778570... + let expected = trim(expected_actual - 0.000000000001); // off by one due to truncation + assert_eq!(result, expected); + + // Q + // 1. would round up -> truncates + // 2. would round up -> truncates + // 3. rounds down + let result = calculate_new_multiplier(1.2, 3.0, 3.2, 2000); + let expected_actual = 1.263584310934; // wolfram alpha: 1.263584310934284... + let expected = expected_actual; + assert_eq!(result, expected); + + // R + // 1. would round up -> truncates + // 2. would round up -> truncates + // 3. would round up -> truncates + let result = calculate_new_multiplier(1.4, 3.0, 3.2, 2000); + let expected_actual = 1.474181696090; // wolfram alpha: 1.474181696089998... + let expected = trim(expected_actual - 0.000000000001); // off by one due to truncation + assert_eq!(result, expected); + } + } + } +} diff --git a/programs/scaled_ui_ext/src/utils/mod.rs b/programs/m_ext/src/utils/mod.rs similarity index 100% rename from programs/scaled_ui_ext/src/utils/mod.rs rename to programs/m_ext/src/utils/mod.rs diff --git a/programs/scaled_ui_ext/src/utils/token.rs b/programs/m_ext/src/utils/token.rs similarity index 100% rename from programs/scaled_ui_ext/src/utils/token.rs rename to programs/m_ext/src/utils/token.rs diff --git a/programs/scaled_ui_ext/src/instructions/mod.rs b/programs/scaled_ui_ext/src/instructions/mod.rs deleted file mode 100644 index b02b98d..0000000 --- a/programs/scaled_ui_ext/src/instructions/mod.rs +++ /dev/null @@ -1,17 +0,0 @@ -pub mod claim_fees; -pub mod initialize; -pub mod set_fee; -pub mod set_m_mint; -pub mod sync; -pub mod unwrap; -pub mod update_wrap_authority; -pub mod wrap; - -pub use claim_fees::*; -pub use initialize::*; -pub use set_fee::*; -pub use set_m_mint::*; -pub use sync::*; -pub use unwrap::*; -pub use update_wrap_authority::*; -pub use wrap::*; diff --git a/programs/scaled_ui_ext/src/instructions/update_wrap_authority.rs b/programs/scaled_ui_ext/src/instructions/update_wrap_authority.rs deleted file mode 100644 index 7a96147..0000000 --- a/programs/scaled_ui_ext/src/instructions/update_wrap_authority.rs +++ /dev/null @@ -1,53 +0,0 @@ -use anchor_lang::prelude::*; - -use crate::{ - errors::ExtError, - state::{ExtGlobal, EXT_GLOBAL_SEED}, -}; - -#[derive(Accounts)] -pub struct UpdateWrapAuthority<'info> { - pub admin: Signer<'info>, - - #[account( - mut, - seeds = [EXT_GLOBAL_SEED], - has_one = admin @ ExtError::NotAuthorized, - bump = global_account.bump, - )] - pub global_account: Account<'info, ExtGlobal>, -} - -impl UpdateWrapAuthority<'_> { - // This instruction allows the admin to update the wrap authority at a specific index - // in the global account's wrap authorities list. - // The new wrap authority must not already exist in the list (unless it's the system program). - // The index must be within bounds of the current wrap authorities. - - pub fn validate(&self, index: u8, new_wrap_authority: Pubkey) -> Result<()> { - // Validate that the new wrap authority is not already in the list (if not the system program) - if new_wrap_authority != Pubkey::default() - && self - .global_account - .wrap_authorities - .contains(&new_wrap_authority) - { - return err!(ExtError::InvalidParam); - } - - // Validate that the index is within bounds - if index as usize >= self.global_account.wrap_authorities.len() { - return err!(ExtError::InvalidParam); - } - - Ok(()) - } - - #[access_control(ctx.accounts.validate(index, new_wrap_authority))] - pub fn handler(ctx: Context, index: u8, new_wrap_authority: Pubkey) -> Result<()> { - // Update the wrap authority at the specified index - ctx.accounts.global_account.wrap_authorities[index as usize] = new_wrap_authority; - - Ok(()) - } -} diff --git a/programs/scaled_ui_ext/src/state.rs b/programs/scaled_ui_ext/src/state.rs deleted file mode 100644 index f81b4f6..0000000 --- a/programs/scaled_ui_ext/src/state.rs +++ /dev/null @@ -1,26 +0,0 @@ -use anchor_lang::prelude::*; - -#[constant] -pub const EXT_GLOBAL_SEED: &[u8] = b"global"; - -#[account] -#[derive(InitSpace)] -pub struct ExtGlobal { - pub admin: Pubkey, // can update config values - pub ext_mint: Pubkey, // m extension mint - pub m_mint: Pubkey, // m mint - pub m_earn_global_account: Pubkey, // m earn global account - pub fee_bps: u64, - pub last_m_index: u64, - pub last_ext_index: u64, - pub bump: u8, - pub m_vault_bump: u8, - pub ext_mint_authority_bump: u8, - pub wrap_authorities: [Pubkey; 10], // wrap authorities -} - -#[constant] -pub const MINT_AUTHORITY_SEED: &[u8] = b"mint_authority"; - -#[constant] -pub const M_VAULT_SEED: &[u8] = b"m_vault"; diff --git a/programs/scaled_ui_ext/src/utils/conversion.rs b/programs/scaled_ui_ext/src/utils/conversion.rs deleted file mode 100644 index 20c17ed..0000000 --- a/programs/scaled_ui_ext/src/utils/conversion.rs +++ /dev/null @@ -1,198 +0,0 @@ -use crate::{ - constants::{INDEX_SCALE_F64, INDEX_SCALE_U64, ONE_HUNDRED_PERCENT_F64}, - errors::ExtError, - state::ExtGlobal, -}; -use anchor_lang::prelude::*; -use anchor_spl::token_interface::{Mint, Token2022, TokenAccount}; -use earn::state::Global as EarnGlobal; -use solana_program::program::invoke_signed; -use spl_token_2022::extension::{ - scaled_ui_amount::{PodF64, ScaledUiAmountConfig, UnixTimestamp}, - BaseStateWithExtensions, StateWithExtensions, -}; - -fn get_latest_multiplier_and_timestamp<'info>( - ext_global_account: &Account<'info, ExtGlobal>, - m_earn_global_account: &Account<'info, EarnGlobal>, -) -> (f64, i64) { - let latest_m_multiplier = m_earn_global_account.index as f64 / INDEX_SCALE_F64; - let cached_m_multiplier = ext_global_account.last_m_index as f64 / INDEX_SCALE_F64; - let latest_timestamp: i64 = m_earn_global_account.timestamp as i64; - let cached_ext_multiplier = ext_global_account.last_ext_index as f64 / INDEX_SCALE_F64; - - // If no change, return early - if latest_m_multiplier == cached_m_multiplier { - return (cached_ext_multiplier, latest_timestamp); - } - - // Calculate the new ext multiplier from the formula: - // new_ext_multiplier = cached_ext_multiplier * (latest_m_multiplier / last_m_multiplier) ^ (1 - fee_on_yield) - // The derivation of this formula is explained in this document: https://gist.github.com/Oighty/89dd1288a0a7fb53eb6f0314846cb746 - let m_increase_factor = latest_m_multiplier / cached_m_multiplier; - - // Calculate the increase factor for the ext index, if the fee is zero, then the increase factor is the same as M - let ext_increase_factor = if ext_global_account.fee_bps == 0 { - m_increase_factor - } else { - // Calculate the increase factor for the ext index - let fee_on_yield = ext_global_account.fee_bps as f64 / ONE_HUNDRED_PERCENT_F64; - // The precision of the powf operation is non-deterministic - // However, the margin of error is ~10^-16, which is smaller than the 10^-12 precision - // that we need for this use case. See: https://doc.rust-lang.org/std/primitive.f64.html#method.powf - m_increase_factor.powf(1.0f64 - fee_on_yield) - }; - - // Calculate the new extension multiplier (index in f64 scaled down) - let new_ext_multiplier = cached_ext_multiplier * ext_increase_factor; - - // We need to round the new multiplier down and truncate at 10^-12 - // to return a consistent value - let new_ext_multiplier = (new_ext_multiplier * INDEX_SCALE_F64).floor() / INDEX_SCALE_F64; - - (new_ext_multiplier, latest_timestamp) -} - -pub fn sync_multiplier<'info>( - ext_mint: &mut InterfaceAccount<'info, Mint>, - ext_global_account: &mut Account<'info, ExtGlobal>, - m_earn_global_account: &Account<'info, EarnGlobal>, - vault_m_token_account: &InterfaceAccount<'info, TokenAccount>, - authority: &AccountInfo<'info>, - authority_seeds: &[&[&[u8]]], - token_program: &Program<'info, Token2022>, -) -> Result { - // Get the current index and timestamp from the m_earn_global_account and cached values - let (multiplier, timestamp): (f64, i64) = - get_latest_multiplier_and_timestamp(ext_global_account, m_earn_global_account); - - // Compare against the current multiplier - // If the multiplier is the same, we don't need to update - { - // explicit scope to drop the borrow at the end of the code block - let ext_account_info = &ext_mint.to_account_info(); - let ext_data = ext_account_info.try_borrow_data()?; - let ext_mint_data = StateWithExtensions::::unpack(&ext_data)?; - let scaled_ui_config = ext_mint_data.get_extension::()?; - - if scaled_ui_config.new_multiplier == PodF64::from(multiplier) - && scaled_ui_config.new_multiplier_effective_timestamp == UnixTimestamp::from(timestamp) - { - return Ok(multiplier); - } - } - - // Update the multiplier and timestamp in the mint account - invoke_signed( - &spl_token_2022::extension::scaled_ui_amount::instruction::update_multiplier( - &token_program.key(), - &ext_mint.key(), - &authority.key(), - &[], - multiplier, - timestamp, - )?, - &[ext_mint.to_account_info(), authority.clone()], - authority_seeds, - )?; - - // Reload the mint account so the new multiplier is reflected - ext_mint.reload()?; - - // Update the last m index and last ext index in the global account - ext_global_account.last_m_index = m_earn_global_account.index; - ext_global_account.last_ext_index = (multiplier * INDEX_SCALE_F64).floor() as u64; - - // Check solvency of the vault - // i.e. that it holds enough M for each extension UI amount - // after the multiplier has been updated - if ext_mint.supply > 0 { - // Calculate the amount of tokens in the vault - let vault_m = vault_m_token_account.amount; - - // Calculate the amount of tokens needed to be solvent - // Reduce it by two to avoid rounding errors (there is an edge cases where the rounding error - // from one index (down) to the next (up) can cause the difference to be 2) - let mut required_m = principal_to_amount_down(ext_mint.supply, multiplier)?; - required_m -= std::cmp::min(2, required_m); - - // Check if the vault has enough tokens - if vault_m < required_m { - return err!(ExtError::InsufficientCollateral); - } - } - - return Ok(multiplier); -} - -pub fn amount_to_principal_down(amount: u64, multiplier: f64) -> Result { - // We want to avoid precision errors with floating point numbers - // Therefore, we use integer math. - let index = (multiplier * INDEX_SCALE_F64).trunc() as u128; - - // Calculate the principal from the amount and index, rounding down - let principal: u64 = (amount as u128) - .checked_mul(INDEX_SCALE_U64 as u128) - .ok_or(ExtError::MathOverflow)? - .checked_div(index) - .ok_or(ExtError::MathUnderflow)? - .try_into()?; - - Ok(principal) -} - -pub fn amount_to_principal_up(amount: u64, multiplier: f64) -> Result { - // We want to avoid precision errors with floating point numbers - // Therefore, we use integer math. - let index = (multiplier * INDEX_SCALE_F64).trunc() as u128; - - // Calculate the principal from the amount and index, rounding up - let principal: u64 = (amount as u128) - .checked_mul(INDEX_SCALE_U64 as u128) - .ok_or(ExtError::MathOverflow)? - .checked_add(index.checked_sub(1u128).ok_or(ExtError::MathUnderflow)?) - .ok_or(ExtError::MathOverflow)? - .checked_div(index) - .ok_or(ExtError::MathUnderflow)? - .try_into()?; - - Ok(principal) -} - -pub fn principal_to_amount_down(principal: u64, multiplier: f64) -> Result { - // We want to avoid precision errors with floating point numbers - // Therefore, we use integer math. - let index = (multiplier * INDEX_SCALE_F64).trunc() as u128; - - // Calculate the amount from the principal and index, rounding down - let amount: u64 = index - .checked_mul(principal as u128) - .ok_or(ExtError::MathOverflow)? - .checked_div(INDEX_SCALE_U64 as u128) - .ok_or(ExtError::MathUnderflow)? - .try_into()?; - - Ok(amount) -} - -pub fn principal_to_amount_up(principal: u64, multiplier: f64) -> Result { - // We want to avoid precision errors with floating point numbers - // Therefore, we use integer math. - let index = (multiplier * INDEX_SCALE_F64).trunc() as u128; - - // Calculate the amount from the principal and index, rounding up - let amount: u64 = index - .checked_mul(principal as u128) - .ok_or(ExtError::MathOverflow)? - .checked_add( - (INDEX_SCALE_U64 as u128) - .checked_sub(1u128) - .ok_or(ExtError::MathUnderflow)?, - ) - .ok_or(ExtError::MathOverflow)? - .checked_div(INDEX_SCALE_U64 as u128) - .ok_or(ExtError::MathUnderflow)? - .try_into()?; - - Ok(amount) -} diff --git a/tests/programs/earn.json b/tests/programs/earn.json new file mode 100644 index 0000000..8479c78 --- /dev/null +++ b/tests/programs/earn.json @@ -0,0 +1,645 @@ +{ + "address": "MzeRokYa9o1ZikH6XHRiSS5nD8mNjZyHpLCBRTBSY4c", + "metadata": { + "name": "earn", + "version": "0.1.0", + "spec": "0.1.0", + "description": "Created with Anchor" + }, + "instructions": [ + { + "name": "add_registrar_earner", + "discriminator": [76, 77, 185, 48, 251, 203, 63, 190], + "accounts": [ + { + "name": "signer", + "writable": true, + "signer": true + }, + { + "name": "global_account", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [103, 108, 111, 98, 97, 108] + } + ] + } + }, + { + "name": "user_token_account" + }, + { + "name": "earner_account", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [101, 97, 114, 110, 101, 114] + }, + { + "kind": "account", + "path": "user_token_account" + } + ] + } + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [ + { + "name": "user", + "type": "pubkey" + }, + { + "name": "proof", + "type": { + "vec": { + "defined": { + "name": "ProofElement" + } + } + } + } + ] + }, + { + "name": "claim_for", + "discriminator": [245, 67, 97, 44, 59, 223, 144, 1], + "accounts": [ + { + "name": "earn_authority", + "signer": true, + "relations": ["global_account"] + }, + { + "name": "global_account", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [103, 108, 111, 98, 97, 108] + } + ] + } + }, + { + "name": "mint", + "writable": true, + "relations": ["global_account"] + }, + { + "name": "token_authority_account", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 116, 111, 107, 101, 110, 95, 97, 117, 116, 104, 111, 114, 105, + 116, 121 + ] + } + ] + } + }, + { + "name": "user_token_account", + "writable": true + }, + { + "name": "earner_account", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [101, 97, 114, 110, 101, 114] + }, + { + "kind": "account", + "path": "earner_account.user_token_account", + "account": "Earner" + } + ] + } + }, + { + "name": "token_program" + }, + { + "name": "mint_multisig" + } + ], + "args": [ + { + "name": "snapshot_balance", + "type": "u64" + } + ] + }, + { + "name": "complete_claims", + "discriminator": [125, 214, 249, 213, 173, 230, 32, 109], + "accounts": [ + { + "name": "earn_authority", + "signer": true, + "relations": ["global_account"] + }, + { + "name": "global_account", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [103, 108, 111, 98, 97, 108] + } + ] + } + } + ], + "args": [] + }, + { + "name": "initialize", + "discriminator": [175, 175, 109, 31, 13, 152, 155, 237], + "accounts": [ + { + "name": "admin", + "writable": true, + "signer": true + }, + { + "name": "global_account", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [103, 108, 111, 98, 97, 108] + } + ] + } + }, + { + "name": "mint" + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [ + { + "name": "earn_authority", + "type": "pubkey" + }, + { + "name": "initial_index", + "type": "u64" + }, + { + "name": "claim_cooldown", + "type": "u64" + } + ] + }, + { + "name": "propagate_index", + "discriminator": [147, 161, 17, 101, 221, 86, 186, 218], + "accounts": [ + { + "name": "signer", + "signer": true + }, + { + "name": "global_account", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [103, 108, 111, 98, 97, 108] + } + ] + } + }, + { + "name": "mint", + "relations": ["global_account"] + } + ], + "args": [ + { + "name": "index", + "type": "u64" + }, + { + "name": "earner_merkle_root", + "type": { + "array": ["u8", 32] + } + } + ] + }, + { + "name": "remove_registrar_earner", + "discriminator": [39, 9, 93, 224, 9, 29, 121, 68], + "accounts": [ + { + "name": "signer", + "writable": true, + "signer": true + }, + { + "name": "global_account", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [103, 108, 111, 98, 97, 108] + } + ] + } + }, + { + "name": "earner_account", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [101, 97, 114, 110, 101, 114] + }, + { + "kind": "account", + "path": "earner_account.user_token_account", + "account": "Earner" + } + ] + } + }, + { + "name": "user_token_account", + "relations": ["earner_account"] + } + ], + "args": [ + { + "name": "proofs", + "type": { + "vec": { + "vec": { + "defined": { + "name": "ProofElement" + } + } + } + } + }, + { + "name": "neighbors", + "type": { + "vec": { + "array": ["u8", 32] + } + } + } + ] + }, + { + "name": "set_claim_cooldown", + "discriminator": [165, 71, 98, 121, 209, 241, 183, 47], + "accounts": [ + { + "name": "admin", + "signer": true, + "relations": ["global_account"] + }, + { + "name": "global_account", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [103, 108, 111, 98, 97, 108] + } + ] + } + } + ], + "args": [ + { + "name": "claim_cooldown", + "type": "u64" + } + ] + }, + { + "name": "set_earn_authority", + "discriminator": [241, 163, 124, 135, 107, 230, 22, 157], + "accounts": [ + { + "name": "admin", + "signer": true, + "relations": ["global_account"] + }, + { + "name": "global_account", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [103, 108, 111, 98, 97, 108] + } + ] + } + } + ], + "args": [ + { + "name": "new_earn_authority", + "type": "pubkey" + } + ] + } + ], + "accounts": [ + { + "name": "Earner", + "discriminator": [236, 126, 51, 96, 46, 225, 103, 207] + }, + { + "name": "Global", + "discriminator": [167, 232, 232, 177, 200, 108, 114, 127] + } + ], + "events": [ + { + "name": "IndexUpdate", + "discriminator": [8, 115, 122, 188, 54, 206, 122, 87] + }, + { + "name": "RewardsClaim", + "discriminator": [84, 168, 212, 108, 203, 10, 250, 107] + } + ], + "errors": [ + { + "code": 6000, + "name": "AlreadyClaimed", + "msg": "Already claimed for user." + }, + { + "code": 6001, + "name": "ExceedsMaxYield", + "msg": "Rewards exceed max yield." + }, + { + "code": 6002, + "name": "NotAuthorized", + "msg": "Invalid signer." + }, + { + "code": 6003, + "name": "InvalidParam", + "msg": "Invalid parameter." + }, + { + "code": 6004, + "name": "AlreadyEarns", + "msg": "User is already an earner." + }, + { + "code": 6005, + "name": "NoActiveClaim", + "msg": "There is no active claim to complete." + }, + { + "code": 6006, + "name": "NotEarning", + "msg": "User is not earning." + }, + { + "code": 6007, + "name": "RequiredAccountMissing", + "msg": "An optional account is required in this case, but not provided." + }, + { + "code": 6008, + "name": "InvalidAccount", + "msg": "Account does not match the expected key." + }, + { + "code": 6009, + "name": "NotActive", + "msg": "Account is not currently active." + }, + { + "code": 6010, + "name": "InvalidProof", + "msg": "Merkle proof verification failed." + }, + { + "code": 6011, + "name": "MutableOwner", + "msg": "Token account owner is required to be immutable." + } + ], + "types": [ + { + "name": "Earner", + "type": { + "kind": "struct", + "fields": [ + { + "name": "last_claim_index", + "type": "u64" + }, + { + "name": "last_claim_timestamp", + "type": "u64" + }, + { + "name": "bump", + "type": "u8" + }, + { + "name": "user", + "type": "pubkey" + }, + { + "name": "user_token_account", + "type": "pubkey" + } + ] + } + }, + { + "name": "Global", + "type": { + "kind": "struct", + "fields": [ + { + "name": "admin", + "type": "pubkey" + }, + { + "name": "earn_authority", + "type": "pubkey" + }, + { + "name": "mint", + "type": "pubkey" + }, + { + "name": "index", + "type": "u64" + }, + { + "name": "timestamp", + "type": "u64" + }, + { + "name": "claim_cooldown", + "type": "u64" + }, + { + "name": "max_supply", + "type": "u64" + }, + { + "name": "max_yield", + "type": "u64" + }, + { + "name": "distributed", + "type": "u64" + }, + { + "name": "claim_complete", + "type": "bool" + }, + { + "name": "earner_merkle_root", + "type": { + "array": ["u8", 32] + } + }, + { + "name": "portal_authority", + "type": "pubkey" + }, + { + "name": "bump", + "type": "u8" + }, + { + "name": "earner_rate", + "type": "u16" + } + ] + } + }, + { + "name": "IndexUpdate", + "type": { + "kind": "struct", + "fields": [ + { + "name": "index", + "type": "u64" + }, + { + "name": "ts", + "type": "u64" + }, + { + "name": "supply", + "type": "u64" + }, + { + "name": "max_yield", + "type": "u64" + } + ] + } + }, + { + "name": "ProofElement", + "type": { + "kind": "struct", + "fields": [ + { + "name": "node", + "type": { + "array": ["u8", 32] + } + }, + { + "name": "on_right", + "type": "bool" + } + ] + } + }, + { + "name": "RewardsClaim", + "type": { + "kind": "struct", + "fields": [ + { + "name": "token_account", + "type": "pubkey" + }, + { + "name": "recipient_token_account", + "type": "pubkey" + }, + { + "name": "amount", + "type": "u64" + }, + { + "name": "ts", + "type": "u64" + }, + { + "name": "index", + "type": "u64" + }, + { + "name": "fee", + "type": "u64" + } + ] + } + } + ], + "constants": [ + { + "name": "EARNER_SEED", + "type": "bytes", + "value": "[101, 97, 114, 110, 101, 114]" + }, + { + "name": "GLOBAL_SEED", + "type": "bytes", + "value": "[103, 108, 111, 98, 97, 108]" + }, + { + "name": "TOKEN_AUTHORITY_SEED", + "type": "bytes", + "value": "[116, 111, 107, 101, 110, 95, 97, 117, 116, 104, 111, 114, 105, 116, 121]" + } + ] +} diff --git a/tests/programs/earn.ts b/tests/programs/earn.ts new file mode 100644 index 0000000..8947dd7 --- /dev/null +++ b/tests/programs/earn.ts @@ -0,0 +1,664 @@ +/** + * Program IDL in camelCase format in order to be used in JS/TS. + * + * Note that this is only a type helper and is not the actual IDL. The original + * IDL can be found at `target/idl/earn.json`. + */ +export type Earn = { + address: "MzeRokYa9o1ZikH6XHRiSS5nD8mNjZyHpLCBRTBSY4c"; + metadata: { + name: "earn"; + version: "0.1.0"; + spec: "0.1.0"; + description: "Created with Anchor"; + }; + instructions: [ + { + name: "addRegistrarEarner"; + discriminator: [76, 77, 185, 48, 251, 203, 63, 190]; + accounts: [ + { + name: "signer"; + writable: true; + signer: true; + }, + { + name: "globalAccount"; + pda: { + seeds: [ + { + kind: "const"; + value: [103, 108, 111, 98, 97, 108]; + } + ]; + }; + }, + { + name: "userTokenAccount"; + }, + { + name: "earnerAccount"; + writable: true; + pda: { + seeds: [ + { + kind: "const"; + value: [101, 97, 114, 110, 101, 114]; + }, + { + kind: "account"; + path: "userTokenAccount"; + } + ]; + }; + }, + { + name: "systemProgram"; + address: "11111111111111111111111111111111"; + } + ]; + args: [ + { + name: "user"; + type: "pubkey"; + }, + { + name: "proof"; + type: { + vec: { + defined: { + name: "proofElement"; + }; + }; + }; + } + ]; + }, + { + name: "claimFor"; + discriminator: [245, 67, 97, 44, 59, 223, 144, 1]; + accounts: [ + { + name: "earnAuthority"; + signer: true; + relations: ["globalAccount"]; + }, + { + name: "globalAccount"; + writable: true; + pda: { + seeds: [ + { + kind: "const"; + value: [103, 108, 111, 98, 97, 108]; + } + ]; + }; + }, + { + name: "mint"; + writable: true; + relations: ["globalAccount"]; + }, + { + name: "tokenAuthorityAccount"; + pda: { + seeds: [ + { + kind: "const"; + value: [ + 116, + 111, + 107, + 101, + 110, + 95, + 97, + 117, + 116, + 104, + 111, + 114, + 105, + 116, + 121 + ]; + } + ]; + }; + }, + { + name: "userTokenAccount"; + writable: true; + }, + { + name: "earnerAccount"; + writable: true; + pda: { + seeds: [ + { + kind: "const"; + value: [101, 97, 114, 110, 101, 114]; + }, + { + kind: "account"; + path: "earner_account.user_token_account"; + account: "earner"; + } + ]; + }; + }, + { + name: "tokenProgram"; + }, + { + name: "mintMultisig"; + } + ]; + args: [ + { + name: "snapshotBalance"; + type: "u64"; + } + ]; + }, + { + name: "completeClaims"; + discriminator: [125, 214, 249, 213, 173, 230, 32, 109]; + accounts: [ + { + name: "earnAuthority"; + signer: true; + relations: ["globalAccount"]; + }, + { + name: "globalAccount"; + writable: true; + pda: { + seeds: [ + { + kind: "const"; + value: [103, 108, 111, 98, 97, 108]; + } + ]; + }; + } + ]; + args: []; + }, + { + name: "initialize"; + discriminator: [175, 175, 109, 31, 13, 152, 155, 237]; + accounts: [ + { + name: "admin"; + writable: true; + signer: true; + }, + { + name: "globalAccount"; + writable: true; + pda: { + seeds: [ + { + kind: "const"; + value: [103, 108, 111, 98, 97, 108]; + } + ]; + }; + }, + { + name: "mint"; + }, + { + name: "systemProgram"; + address: "11111111111111111111111111111111"; + } + ]; + args: [ + { + name: "earnAuthority"; + type: "pubkey"; + }, + { + name: "initialIndex"; + type: "u64"; + }, + { + name: "claimCooldown"; + type: "u64"; + } + ]; + }, + { + name: "propagateIndex"; + discriminator: [147, 161, 17, 101, 221, 86, 186, 218]; + accounts: [ + { + name: "signer"; + signer: true; + }, + { + name: "globalAccount"; + writable: true; + pda: { + seeds: [ + { + kind: "const"; + value: [103, 108, 111, 98, 97, 108]; + } + ]; + }; + }, + { + name: "mint"; + relations: ["globalAccount"]; + } + ]; + args: [ + { + name: "index"; + type: "u64"; + }, + { + name: "earnerMerkleRoot"; + type: { + array: ["u8", 32]; + }; + } + ]; + }, + { + name: "removeRegistrarEarner"; + discriminator: [39, 9, 93, 224, 9, 29, 121, 68]; + accounts: [ + { + name: "signer"; + writable: true; + signer: true; + }, + { + name: "globalAccount"; + pda: { + seeds: [ + { + kind: "const"; + value: [103, 108, 111, 98, 97, 108]; + } + ]; + }; + }, + { + name: "earnerAccount"; + writable: true; + pda: { + seeds: [ + { + kind: "const"; + value: [101, 97, 114, 110, 101, 114]; + }, + { + kind: "account"; + path: "earner_account.user_token_account"; + account: "earner"; + } + ]; + }; + }, + { + name: "userTokenAccount"; + relations: ["earnerAccount"]; + } + ]; + args: [ + { + name: "proofs"; + type: { + vec: { + vec: { + defined: { + name: "proofElement"; + }; + }; + }; + }; + }, + { + name: "neighbors"; + type: { + vec: { + array: ["u8", 32]; + }; + }; + } + ]; + }, + { + name: "setClaimCooldown"; + discriminator: [165, 71, 98, 121, 209, 241, 183, 47]; + accounts: [ + { + name: "admin"; + signer: true; + relations: ["globalAccount"]; + }, + { + name: "globalAccount"; + writable: true; + pda: { + seeds: [ + { + kind: "const"; + value: [103, 108, 111, 98, 97, 108]; + } + ]; + }; + } + ]; + args: [ + { + name: "claimCooldown"; + type: "u64"; + } + ]; + }, + { + name: "setEarnAuthority"; + discriminator: [241, 163, 124, 135, 107, 230, 22, 157]; + accounts: [ + { + name: "admin"; + signer: true; + relations: ["globalAccount"]; + }, + { + name: "globalAccount"; + writable: true; + pda: { + seeds: [ + { + kind: "const"; + value: [103, 108, 111, 98, 97, 108]; + } + ]; + }; + } + ]; + args: [ + { + name: "newEarnAuthority"; + type: "pubkey"; + } + ]; + } + ]; + accounts: [ + { + name: "earner"; + discriminator: [236, 126, 51, 96, 46, 225, 103, 207]; + }, + { + name: "global"; + discriminator: [167, 232, 232, 177, 200, 108, 114, 127]; + } + ]; + events: [ + { + name: "indexUpdate"; + discriminator: [8, 115, 122, 188, 54, 206, 122, 87]; + }, + { + name: "rewardsClaim"; + discriminator: [84, 168, 212, 108, 203, 10, 250, 107]; + } + ]; + errors: [ + { + code: 6000; + name: "alreadyClaimed"; + msg: "Already claimed for user."; + }, + { + code: 6001; + name: "exceedsMaxYield"; + msg: "Rewards exceed max yield."; + }, + { + code: 6002; + name: "notAuthorized"; + msg: "Invalid signer."; + }, + { + code: 6003; + name: "invalidParam"; + msg: "Invalid parameter."; + }, + { + code: 6004; + name: "alreadyEarns"; + msg: "User is already an earner."; + }, + { + code: 6005; + name: "noActiveClaim"; + msg: "There is no active claim to complete."; + }, + { + code: 6006; + name: "notEarning"; + msg: "User is not earning."; + }, + { + code: 6007; + name: "requiredAccountMissing"; + msg: "An optional account is required in this case, but not provided."; + }, + { + code: 6008; + name: "invalidAccount"; + msg: "Account does not match the expected key."; + }, + { + code: 6009; + name: "notActive"; + msg: "Account is not currently active."; + }, + { + code: 6010; + name: "invalidProof"; + msg: "Merkle proof verification failed."; + }, + { + code: 6011; + name: "mutableOwner"; + msg: "Token account owner is required to be immutable."; + } + ]; + types: [ + { + name: "earner"; + type: { + kind: "struct"; + fields: [ + { + name: "lastClaimIndex"; + type: "u64"; + }, + { + name: "lastClaimTimestamp"; + type: "u64"; + }, + { + name: "bump"; + type: "u8"; + }, + { + name: "user"; + type: "pubkey"; + }, + { + name: "userTokenAccount"; + type: "pubkey"; + } + ]; + }; + }, + { + name: "global"; + type: { + kind: "struct"; + fields: [ + { + name: "admin"; + type: "pubkey"; + }, + { + name: "earnAuthority"; + type: "pubkey"; + }, + { + name: "mint"; + type: "pubkey"; + }, + { + name: "index"; + type: "u64"; + }, + { + name: "timestamp"; + type: "u64"; + }, + { + name: "claimCooldown"; + type: "u64"; + }, + { + name: "maxSupply"; + type: "u64"; + }, + { + name: "maxYield"; + type: "u64"; + }, + { + name: "distributed"; + type: "u64"; + }, + { + name: "claimComplete"; + type: "bool"; + }, + { + name: "earnerMerkleRoot"; + type: { + array: ["u8", 32]; + }; + }, + { + name: "portalAuthority"; + type: "pubkey"; + }, + { + name: "bump"; + type: "u8"; + }, + { + name: "earnerRate"; + type: "u16"; + } + ]; + }; + }, + { + name: "indexUpdate"; + type: { + kind: "struct"; + fields: [ + { + name: "index"; + type: "u64"; + }, + { + name: "ts"; + type: "u64"; + }, + { + name: "supply"; + type: "u64"; + }, + { + name: "maxYield"; + type: "u64"; + } + ]; + }; + }, + { + name: "proofElement"; + type: { + kind: "struct"; + fields: [ + { + name: "node"; + type: { + array: ["u8", 32]; + }; + }, + { + name: "onRight"; + type: "bool"; + } + ]; + }; + }, + { + name: "rewardsClaim"; + type: { + kind: "struct"; + fields: [ + { + name: "tokenAccount"; + type: "pubkey"; + }, + { + name: "recipientTokenAccount"; + type: "pubkey"; + }, + { + name: "amount"; + type: "u64"; + }, + { + name: "ts"; + type: "u64"; + }, + { + name: "index"; + type: "u64"; + }, + { + name: "fee"; + type: "u64"; + } + ]; + }; + } + ]; + constants: [ + { + name: "earnerSeed"; + type: "bytes"; + value: "[101, 97, 114, 110, 101, 114]"; + }, + { + name: "globalSeed"; + type: "bytes"; + value: "[103, 108, 111, 98, 97, 108]"; + }, + { + name: "tokenAuthoritySeed"; + type: "bytes"; + value: "[116, 111, 107, 101, 110, 95, 97, 117, 116, 104, 111, 114, 105, 116, 121]"; + } + ]; +}; diff --git a/tests/programs/ext_a.so b/tests/programs/ext_a.so new file mode 100755 index 0000000..485a02b Binary files /dev/null and b/tests/programs/ext_a.so differ diff --git a/tests/programs/ext_b.so b/tests/programs/ext_b.so new file mode 100755 index 0000000..c886395 Binary files /dev/null and b/tests/programs/ext_b.so differ diff --git a/tests/programs/ext_c.so b/tests/programs/ext_c.so new file mode 100755 index 0000000..1402911 Binary files /dev/null and b/tests/programs/ext_c.so differ diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 5bd2440..9543dcc 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -1,7 +1,65 @@ - export function toFixedSizedArray(buffer: Buffer, size: number): number[] { - const array = new Array(size).fill(0); - buffer.forEach((value, index) => { - array[index] = value; - }); - return array; -} \ No newline at end of file +import { struct, u8, f64 } from "@solana/buffer-layout"; +import { publicKey, u64 } from "@solana/buffer-layout-utils"; +import { PublicKey } from "@solana/web3.js"; + +// Byte utilities +export function toFixedSizedArray(buffer: Buffer, size: number): number[] { + const array = new Array(size).fill(0); + buffer.forEach((value, index) => { + array[index] = value; + }); + return array; +} + +export const ZERO_WORD = new Array(32).fill(0); + +export const padKeyArray = (array: PublicKey[], desiredLen: number) => { + const currentLen = array.length; + + if (currentLen > desiredLen) { + throw new Error("Array is too long"); + } + + const padding = new Array(desiredLen - currentLen).fill(PublicKey.default); + return array.concat(padding); +}; + +export const createUniqueKeyArray = (size: number) => { + return new Array(size).fill(PublicKey.default).map((_, i, arr) => { + let key = PublicKey.unique(); + while (key.equals(PublicKey.default) || arr.includes(key)) { + key = PublicKey.unique(); + } + return key; + }); +}; + +// Scaled UI Amount Config Extension Types and Functions since not supported in spl-token library yet +interface InitializeScaledUiAmountConfigData { + instruction: 43; + scaledUiAmountInstruction: 0; + authority: PublicKey | null; + multiplier: number; +} + +export const InitializeScaledUiAmountConfigInstructionData = + struct([ + u8("instruction"), + u8("scaledUiAmountInstruction"), + publicKey("authority"), + f64("multiplier"), + ]); + +export interface ScaledUiAmountConfig { + authority: PublicKey; + multiplier: number; + newMultiplierEffectiveTimestamp: bigint; + newMultiplier: number; +} + +export const ScaledUiAmountConfigLayout = struct([ + publicKey("authority"), + f64("multiplier"), + u64("newMultiplierEffectiveTimestamp"), + f64("newMultiplier"), +]); diff --git a/tests/unit/ext_swap.test.ts b/tests/unit/ext_swap.test.ts new file mode 100644 index 0000000..4ac67fb --- /dev/null +++ b/tests/unit/ext_swap.test.ts @@ -0,0 +1,959 @@ +import NodeWallet from "@coral-xyz/anchor/dist/cjs/nodewallet"; +import { + createAssociatedTokenAccountInstruction, + createInitializeMintInstruction, + createInitializeScaledUiAmountConfigInstruction, + createMintToInstruction, + ExtensionType, + getAccount, + getAssociatedTokenAddressSync, + getMintLen, + TOKEN_2022_PROGRAM_ID, +} from "@solana/spl-token"; +import { + Connection, + Keypair, + LAMPORTS_PER_SOL, + PublicKey, + SystemProgram, + Transaction, +} from "@solana/web3.js"; +import { fromWorkspace, LiteSVMProvider } from "anchor-litesvm"; +import { + PROGRAM_ID as EARN_PROGRAM_ID, + MerkleTree, +} from "@m0-foundation/solana-m-sdk"; +import { Earn } from "../../tests/programs/earn"; +import EARN from "../../tests/programs/earn.json"; +import EXT_SWAP from "../../target/idl/ext_swap.json"; +import M_EXT from "../../target/idl/scaled_ui.json"; +import { BN, Program } from "@coral-xyz/anchor"; +import { ExtSwap } from "../../target/types/ext_swap"; +import { TransactionMetadata } from "litesvm"; +import { MExt } from "../../target/types/scaled_ui"; + +describe("extension swap tests", () => { + const { + admin, + swapper, + mMint, + extProgramA, + extProgramB, + mintA, + mintB, + multisig, + extProgramC, + mintC, + } = loadKeypairs(); + + const svm = fromWorkspace("").withSplPrograms(); + svm.airdrop(admin.publicKey, BigInt(10 * LAMPORTS_PER_SOL)); + svm.airdrop(swapper.publicKey, BigInt(10 * LAMPORTS_PER_SOL)); + + // M Earn program + svm.addProgramFromFile(EARN_PROGRAM_ID, "tests/programs/earn.so"); + + // Sample extension programs for swapping + svm.addProgramFromFile(extProgramA.publicKey, "tests/programs/ext_a.so"); + svm.addProgramFromFile(extProgramB.publicKey, "tests/programs/ext_b.so"); + svm.addProgramFromFile(extProgramC.publicKey, "tests/programs/ext_c.so"); + + // Replace the default token2022 program with updated one + svm.addProgramFromFile( + TOKEN_2022_PROGRAM_ID, + "tests/programs/spl_token_2022.so" + ); + + // Anchor providers and programs + const provider = new LiteSVMProvider(svm, new NodeWallet(admin)); + const program = new Program(EXT_SWAP, provider); + const earn = new Program(EARN, provider); + + const [extensionA, extensionB, extensionC] = [ + extProgramA, + extProgramB, + extProgramC, + ].map((p) => new Program({ ...M_EXT, address: p.publicKey }, provider)); + + // Common accounts + const accounts = { + ataA: getAssociatedTokenAddressSync( + mintA.publicKey, + swapper.publicKey, + true, + TOKEN_2022_PROGRAM_ID + ), + ataB: getAssociatedTokenAddressSync( + mintB.publicKey, + swapper.publicKey, + true, + TOKEN_2022_PROGRAM_ID + ), + ataM: getAssociatedTokenAddressSync( + mMint.publicKey, + swapper.publicKey, + true, + TOKEN_2022_PROGRAM_ID + ), + }; + + // Helper for sending transactions and checking errors + const sendTransaction = async ( + txn: Transaction | Promise, + signers: Keypair[], + expectedErrorMessage?: RegExp + ): Promise => { + if (txn instanceof Promise) { + txn = await txn; + } + + txn.feePayer = signers[0].publicKey; + txn.recentBlockhash = svm.latestBlockhash(); + txn.sign(...signers); + + const result = svm.sendTransaction(txn); + + if ("err" in result) { + if (expectedErrorMessage) { + for (const log of result.meta().logs()) { + if (log.match(expectedErrorMessage)) return null; + } + + console.error(result.toString()); + throw new Error("Did not find expected error message in logs"); + } + + console.error(result.toString()); + throw new Error("Transaction failed"); + } + if (expectedErrorMessage) { + console.error(result.toString()); + throw new Error("Expected transaction to fail, but it succeeded"); + } + + return result; + }; + + const getTokenBalance = async (ata: PublicKey) => { + const act = await getAccount( + provider.connection, + ata, + undefined, + TOKEN_2022_PROGRAM_ID + ); + return Number(act.amount); + }; + + describe("initialize swap programs", () => { + it("create mints", async () => { + // Mint auth for each program + const [mintAuthA, mintAuthB, mintAuthC] = [ + extensionA, + extensionB, + extensionC, + ].map( + (p) => + PublicKey.findProgramAddressSync( + [Buffer.from("mint_authority")], + p.programId + )[0] + ); + + // Create all mints + for (const [mint, mintAuth] of [ + [mMint, admin.publicKey], + [mintA, mintAuthA], + [mintB, mintAuthB], + [mintC, mintAuthC], + ] as [Keypair, PublicKey][]) { + await sendTransaction( + await buildMintTxn( + provider.connection, + admin.publicKey, + mint, + mintAuth + ), + [admin, mint] + ); + } + + // Mint M to swapper + const transaction = new Transaction().add( + createAssociatedTokenAccountInstruction( + admin.publicKey, + accounts.ataM, + swapper.publicKey, + mMint.publicKey, + TOKEN_2022_PROGRAM_ID + ), + createMintToInstruction( + mMint.publicKey, + accounts.ataM, + admin.publicKey, + 1e6, + [], + TOKEN_2022_PROGRAM_ID + ) + ); + await sendTransaction(transaction, [admin]); + }); + it("initialize earn program", async () => { + await sendTransaction( + earn.methods + .initialize(multisig.publicKey, new BN(1_000_000_000_000), new BN(0)) + .accounts({ + mint: mMint.publicKey, + }) + .transaction(), + [admin] + ); + + const getVault = (p: PublicKey) => + PublicKey.findProgramAddressSync([Buffer.from("m_vault")], p)[0]; + + // Add all vaults as earners + const earnerMerkleTree = new MerkleTree([ + getVault(extensionA.programId), + getVault(extensionB.programId), + getVault(extensionC.programId), + ]); + + await earn.methods + .propagateIndex(new BN(1_000_000_000_001), earnerMerkleTree.getRoot()) + .accountsPartial({ + mint: mMint.publicKey, + }) + .rpc(); + + for (const p of [extensionA, extensionB, extensionC]) { + const vault = getVault(p.programId); + + const ata = await getAssociatedTokenAddressSync( + mMint.publicKey, + vault, + true, + TOKEN_2022_PROGRAM_ID + ); + + // Create ata + await sendTransaction( + new Transaction().add( + createAssociatedTokenAccountInstruction( + admin.publicKey, + ata, + vault, + mMint.publicKey, + TOKEN_2022_PROGRAM_ID + ) + ), + [admin] + ); + + // Create earner account + const { proof } = earnerMerkleTree.getInclusionProof(vault); + await earn.methods + .addRegistrarEarner(vault, proof) + .accounts({ + userTokenAccount: ata, + }) + .rpc(); + } + }); + it("initialize extension programs", async () => { + for (const [i, p] of [extensionA, extensionB, extensionC].entries()) { + await sendTransaction( + p.methods + .initialize([], new BN(0)) + .accounts({ + mMint: mMint.publicKey, + extMint: [mintA, mintB, mintC][i].publicKey, + }) + .transaction(), + [admin] + ); + } + }); + }); + + // Tests + describe("configure", () => { + it("initialize config", async () => { + await sendTransaction( + program.methods + .initializeGlobal(mMint.publicKey) + .accounts({}) + .transaction(), + [admin] + ); + }); + + it("re-initialize config revert", async () => { + await sendTransaction( + program.methods + .initializeGlobal(mMint.publicKey) + .accounts({ admin: swapper.publicKey }) + .transaction(), + [swapper], + /Allocate: account Address .* already in use/ + ); + }); + + it("add to ext whitelist", async () => { + await sendTransaction( + program.methods + .whitelistExtension(earn.programId) + .accounts({}) + .transaction(), + [admin] + ); + + const { whitelistedExtensions } = await program.account.swapGlobal.fetch( + PublicKey.findProgramAddressSync( + [Buffer.from("global")], + program.programId + )[0] + ); + + // Validate the extension was added + expect(whitelistedExtensions).toHaveLength(1); + expect(whitelistedExtensions[0].toBase58()).toBe( + earn.programId.toBase58() + ); + }); + + it("add to unwrap whitelist", async () => { + await sendTransaction( + program.methods + .whitelistUnwrapper(admin.publicKey) + .accounts({}) + .transaction(), + [admin] + ); + + const { whitelistedExtensions, whitelistedUnwrappers } = + await program.account.swapGlobal.fetch( + PublicKey.findProgramAddressSync( + [Buffer.from("global")], + program.programId + )[0] + ); + + // Validate whitelists + expect(whitelistedExtensions).toHaveLength(1); + expect(whitelistedExtensions[0].toBase58()).toBe( + earn.programId.toBase58() + ); + expect(whitelistedUnwrappers).toHaveLength(1); + expect(whitelistedUnwrappers[0].toBase58()).toBe( + admin.publicKey.toBase58() + ); + }); + + it("remove non-existent entry", async () => { + await sendTransaction( + program.methods + .removeWhitelistedExtension(new Keypair().publicKey) + .accounts({}) + .transaction(), + [admin], + /Error Message: Extension is not whitelisted/ + ); + }); + + it("remove from unwrap whitelist", async () => { + await sendTransaction( + program.methods + .removeWhitelistedUnwrapper(admin.publicKey) + .accounts({}) + .transaction(), + [admin] + ); + + const { whitelistedExtensions, whitelistedUnwrappers } = + await program.account.swapGlobal.fetch( + PublicKey.findProgramAddressSync( + [Buffer.from("global")], + program.programId + )[0] + ); + + // Validate whitelists + expect(whitelistedExtensions).toHaveLength(1); + expect(whitelistedExtensions[0].toBase58()).toBe( + earn.programId.toBase58() + ); + expect(whitelistedUnwrappers).toHaveLength(0); + }); + + it("remove from ext whitelist", async () => { + await sendTransaction( + program.methods + .removeWhitelistedExtension(earn.programId) + .accounts({}) + .transaction(), + [admin] + ); + + const { whitelistedExtensions } = await program.account.swapGlobal.fetch( + PublicKey.findProgramAddressSync( + [Buffer.from("global")], + program.programId + )[0] + ); + + // Validate the extension was removed + expect(whitelistedExtensions).toHaveLength(0); + }); + }); + + describe("swapping", () => { + it("extension not whitelisted", async () => { + await sendTransaction( + program.methods + .wrap(new BN(1e2)) + .accounts({ + signer: swapper.publicKey, + wrapAuthority: program.programId, + mTokenProgram: TOKEN_2022_PROGRAM_ID, + toExtProgram: extProgramA.publicKey, + toMint: mintA.publicKey, + toTokenProgram: TOKEN_2022_PROGRAM_ID, + }) + .transaction(), + [swapper], + /Error Message: Extension is not whitelisted/ + ); + + // Whitelist both extensions + for (const pid of [extProgramA, extProgramB, extProgramC]) { + await sendTransaction( + program.methods + .whitelistExtension(pid.publicKey) + .accounts({}) + .transaction(), + [admin] + ); + } + }); + + it("swap program not whitelisted for wrapping", async () => { + await sendTransaction( + program.methods + .wrap(new BN(1e3)) + .accounts({ + signer: swapper.publicKey, + wrapAuthority: program.programId, + mTokenProgram: TOKEN_2022_PROGRAM_ID, + toExtProgram: extProgramA.publicKey, + toMint: mintA.publicKey, + toTokenProgram: TOKEN_2022_PROGRAM_ID, + }) + .transaction(), + [swapper], + /Error Message: Invalid signer/ + ); + + // Whitelist swap program signer + for (const p of [extensionA, extensionB, extensionC]) { + const [global] = PublicKey.findProgramAddressSync( + [Buffer.from("global")], + program.programId + ); + + await sendTransaction( + p.methods.addWrapAuthority(global).accounts({}).transaction(), + [admin] + ); + } + }); + + it("wrap M", async () => { + await sendTransaction( + program.methods + .wrap(new BN(1e4)) + .accounts({ + signer: swapper.publicKey, + wrapAuthority: program.programId, + mTokenProgram: TOKEN_2022_PROGRAM_ID, + toExtProgram: extProgramA.publicKey, + toMint: mintA.publicKey, + toTokenProgram: TOKEN_2022_PROGRAM_ID, + }) + .transaction(), + [swapper] + ); + + // Validate amounts + expect(await getTokenBalance(accounts.ataM)).toBe(0.99e6); + expect(await getTokenBalance(accounts.ataA)).toBe(0.01e6); + }); + + it("unauthorized unwrap to M", async () => { + await sendTransaction( + program.methods + .unwrap(new BN(1e1)) + .accounts({ + signer: swapper.publicKey, + unwrapAuthority: program.programId, + mTokenProgram: TOKEN_2022_PROGRAM_ID, + fromExtProgram: extProgramA.publicKey, + fromMint: mintA.publicKey, + fromTokenProgram: TOKEN_2022_PROGRAM_ID, + }) + .transaction(), + [swapper], + /Error Message: Signer is not whitelisted/ + ); + }); + + it("unwrap to M", async () => { + // add swapper + await sendTransaction( + program.methods + .whitelistUnwrapper(swapper.publicKey) + .accounts({}) + .transaction(), + [admin] + ); + + await sendTransaction( + program.methods + .unwrap(new BN(1e3)) + .accounts({ + signer: swapper.publicKey, + unwrapAuthority: program.programId, + mTokenProgram: TOKEN_2022_PROGRAM_ID, + fromExtProgram: extProgramA.publicKey, + fromMint: mintA.publicKey, + fromTokenProgram: TOKEN_2022_PROGRAM_ID, + }) + .transaction(), + [swapper] + ); + + // Validate amounts + expect(await getTokenBalance(accounts.ataM)).toBe(0.991e6); + expect(await getTokenBalance(accounts.ataA)).toBe(0.009e6); + }); + + it("swap extension tokens", async () => { + await sendTransaction( + program.methods + .swap(new BN(1e3), 0) + .accounts({ + signer: swapper.publicKey, + unwrapAuthority: program.programId, + wrapAuthority: program.programId, + mTokenProgram: TOKEN_2022_PROGRAM_ID, + fromExtProgram: extProgramA.publicKey, + toExtProgram: extProgramB.publicKey, + fromMint: mintA.publicKey, + toMint: mintB.publicKey, + fromTokenAccount: accounts.ataA, + toTokenProgram: TOKEN_2022_PROGRAM_ID, + fromTokenProgram: TOKEN_2022_PROGRAM_ID, + }) + .transaction(), + [swapper] + ); + + // Validate amounts + expect(await getTokenBalance(accounts.ataM)).toBe(0.991e6); + expect(await getTokenBalance(accounts.ataA)).toBe(0.008e6); + expect(await getTokenBalance(accounts.ataB)).toBe(0.001e6); + }); + }); + + describe("remaining accounts", () => { + it("invalid index", async () => { + await sendTransaction( + program.methods + .swap(new BN(1e2), 1) + .accounts({ + signer: swapper.publicKey, + unwrapAuthority: program.programId, + wrapAuthority: program.programId, + mTokenProgram: TOKEN_2022_PROGRAM_ID, + fromExtProgram: extProgramA.publicKey, + toExtProgram: extProgramB.publicKey, + fromMint: mintA.publicKey, + toMint: mintB.publicKey, + fromTokenAccount: accounts.ataA, + toTokenProgram: TOKEN_2022_PROGRAM_ID, + fromTokenProgram: TOKEN_2022_PROGRAM_ID, + }) + .transaction(), + [swapper], + /Error Message: Index invalid for length of the array/ + ); + }); + + it("swap with unneeded remaining accounts", async () => { + await sendTransaction( + program.methods + .swap(new BN(1e3), 1) + .accounts({ + signer: swapper.publicKey, + unwrapAuthority: program.programId, + wrapAuthority: program.programId, + mTokenProgram: TOKEN_2022_PROGRAM_ID, + fromExtProgram: extProgramA.publicKey, + toExtProgram: extProgramB.publicKey, + fromMint: mintA.publicKey, + toMint: mintB.publicKey, + fromTokenAccount: accounts.ataA, + toTokenProgram: TOKEN_2022_PROGRAM_ID, + fromTokenProgram: TOKEN_2022_PROGRAM_ID, + }) + .remainingAccounts([ + { + pubkey: new Keypair().publicKey, + isSigner: false, + isWritable: false, + }, + { + pubkey: new Keypair().publicKey, + isSigner: false, + isWritable: false, + }, + ]) + .transaction(), + [swapper] + ); + + // Validate amounts + expect(await getTokenBalance(accounts.ataM)).toBe(0.991e6); + expect(await getTokenBalance(accounts.ataA)).toBe(0.007e6); + expect(await getTokenBalance(accounts.ataB)).toBe(0.002e6); + }); + + it("wrap expects remaining account", async () => { + await sendTransaction( + program.methods + .swap(new BN(1e3), 0) + .accounts({ + signer: swapper.publicKey, + unwrapAuthority: program.programId, + wrapAuthority: program.programId, + mTokenProgram: TOKEN_2022_PROGRAM_ID, + fromExtProgram: extProgramA.publicKey, + toExtProgram: extProgramC.publicKey, + fromMint: mintA.publicKey, + toMint: mintC.publicKey, + fromTokenAccount: accounts.ataA, + toTokenProgram: TOKEN_2022_PROGRAM_ID, + fromTokenProgram: TOKEN_2022_PROGRAM_ID, + }) + .transaction(), + [swapper], + /Error Message: Not enough account keys given to the instruction/ + ); + }); + + it("wrap gets incorrect remaining account", async () => { + await sendTransaction( + program.methods + .swap(new BN(1e3), 0) + .accounts({ + signer: swapper.publicKey, + unwrapAuthority: program.programId, + wrapAuthority: program.programId, + mTokenProgram: TOKEN_2022_PROGRAM_ID, + fromExtProgram: extProgramA.publicKey, + toExtProgram: extProgramC.publicKey, + fromMint: mintA.publicKey, + toMint: mintC.publicKey, + fromTokenAccount: accounts.ataA, + toTokenProgram: TOKEN_2022_PROGRAM_ID, + fromTokenProgram: TOKEN_2022_PROGRAM_ID, + }) + .remainingAccounts([ + { + pubkey: new Keypair().publicKey, + isSigner: false, + isWritable: false, + }, + ]) + .transaction(), + [swapper], + /Error Message: Program ID was not as expected/ + ); + }); + + it("wrap gets expected remaining account", async () => { + await sendTransaction( + program.methods + .swap(new BN(1e3), 0) + .accounts({ + signer: swapper.publicKey, + unwrapAuthority: program.programId, + wrapAuthority: program.programId, + mTokenProgram: TOKEN_2022_PROGRAM_ID, + fromExtProgram: extProgramA.publicKey, + toExtProgram: extProgramC.publicKey, + fromMint: mintA.publicKey, + toMint: mintC.publicKey, + fromTokenAccount: accounts.ataA, + toTokenProgram: TOKEN_2022_PROGRAM_ID, + fromTokenProgram: TOKEN_2022_PROGRAM_ID, + }) + .remainingAccounts([ + { + pubkey: TOKEN_2022_PROGRAM_ID, + isSigner: false, + isWritable: false, + }, + ]) + .transaction(), + [swapper] + ); + }); + }); + + describe("remove extension", () => { + it("remove from ext whitelist", async () => { + await sendTransaction( + program.methods + .removeWhitelistedExtension(extProgramC.publicKey) + .accounts({}) + .transaction(), + [admin] + ); + }); + + it("swap to extension that was removed", async () => { + await sendTransaction( + program.methods + .swap(new BN(1e3), 0) + .accounts({ + signer: swapper.publicKey, + unwrapAuthority: program.programId, + wrapAuthority: program.programId, + mTokenProgram: TOKEN_2022_PROGRAM_ID, + fromExtProgram: extProgramB.publicKey, + toExtProgram: extProgramC.publicKey, + fromMint: mintB.publicKey, + toMint: mintC.publicKey, + fromTokenAccount: accounts.ataB, + toTokenProgram: TOKEN_2022_PROGRAM_ID, + fromTokenProgram: TOKEN_2022_PROGRAM_ID, + }) + .transaction(), + [swapper], + /Error Message: Extension is not whitelisted/ + ); + }); + }); + + describe("swap program not whitelisted", () => { + it("attempt wrap without authority", async () => { + // Remove swap program as wrap authority + const [global] = PublicKey.findProgramAddressSync( + [Buffer.from("global")], + program.programId + ); + + await sendTransaction( + extensionA.methods + .removeWrapAuthority(global) + .accounts({}) + .transaction(), + [admin] + ); + + // Try to wrap + await sendTransaction( + program.methods + .wrap(new BN(1e1)) + .accounts({ + signer: swapper.publicKey, + wrapAuthority: program.programId, + mTokenProgram: TOKEN_2022_PROGRAM_ID, + toExtProgram: extProgramA.publicKey, + toMint: mintA.publicKey, + toTokenProgram: TOKEN_2022_PROGRAM_ID, + }) + .transaction(), + [swapper], + /Error Message: Invalid signer/ + ); + }); + + it("attempt wrap with invalid authority", async () => { + // Try to wrap + await sendTransaction( + program.methods + .wrap(new BN(1e1)) + .accounts({ + signer: swapper.publicKey, + wrapAuthority: admin.publicKey, + mTokenProgram: TOKEN_2022_PROGRAM_ID, + toExtProgram: extProgramA.publicKey, + toMint: mintA.publicKey, + toTokenProgram: TOKEN_2022_PROGRAM_ID, + }) + .transaction(), + [swapper, admin], + /Error Message: Invalid signer/ + ); + }); + + it("wrap with wrap authority", async () => { + // add wrap authority + await sendTransaction( + extensionA.methods + .addWrapAuthority(admin.publicKey) + .accounts({}) + .transaction(), + [admin] + ); + + await sendTransaction( + program.methods + .wrap(new BN(1e2)) + .accounts({ + signer: swapper.publicKey, + wrapAuthority: admin.publicKey, + mTokenProgram: TOKEN_2022_PROGRAM_ID, + toExtProgram: extProgramA.publicKey, + toMint: mintA.publicKey, + toTokenProgram: TOKEN_2022_PROGRAM_ID, + }) + .transaction(), + [swapper, admin] + ); + }); + + it("unwrap authority set instead of wrap", async () => { + await sendTransaction( + program.methods + .swap(new BN(15), 0) + .accounts({ + signer: swapper.publicKey, + wrapAuthority: program.programId, + unwrapAuthority: admin.publicKey, + mTokenProgram: TOKEN_2022_PROGRAM_ID, + fromExtProgram: extProgramB.publicKey, + toExtProgram: extProgramA.publicKey, + fromMint: mintB.publicKey, + toMint: mintA.publicKey, + fromTokenAccount: accounts.ataB, + toTokenProgram: TOKEN_2022_PROGRAM_ID, + fromTokenProgram: TOKEN_2022_PROGRAM_ID, + }) + .transaction(), + [swapper, admin], + /Error Message: Invalid signer/ + ); + }); + + it("swap with wrap authority", async () => { + await sendTransaction( + program.methods + .swap(new BN(15), 0) + .accounts({ + signer: swapper.publicKey, + unwrapAuthority: program.programId, + wrapAuthority: admin.publicKey, + mTokenProgram: TOKEN_2022_PROGRAM_ID, + fromExtProgram: extProgramB.publicKey, + toExtProgram: extProgramA.publicKey, + fromMint: mintB.publicKey, + toMint: mintA.publicKey, + fromTokenAccount: accounts.ataB, + toTokenProgram: TOKEN_2022_PROGRAM_ID, + fromTokenProgram: TOKEN_2022_PROGRAM_ID, + }) + .transaction(), + [swapper, admin] + ); + }); + }); +}); + +async function buildMintTxn( + connection: Connection, + creator: PublicKey, + mint: Keypair, + mintAuthority: PublicKey +) { + const mintLen = getMintLen([ExtensionType.ScaledUiAmountConfig]); + const mintLamports = await connection.getMinimumBalanceForRentExemption( + mintLen + ); + + const createMintAccount = SystemProgram.createAccount({ + fromPubkey: creator, + newAccountPubkey: mint.publicKey, + space: mintLen, + lamports: mintLamports, + programId: TOKEN_2022_PROGRAM_ID, + }); + + const initializeScaledUiAmountConfig = + createInitializeScaledUiAmountConfigInstruction( + mint.publicKey, + mintAuthority, + 1.0, + TOKEN_2022_PROGRAM_ID + ); + + const initializeMint = createInitializeMintInstruction( + mint.publicKey, + 6, + mintAuthority, + mintAuthority, + TOKEN_2022_PROGRAM_ID + ); + + let tx = new Transaction(); + tx.add(createMintAccount, initializeScaledUiAmountConfig, initializeMint); + return tx; +} + +function loadKeypairs() { + const key = (k: string) => Keypair.fromSecretKey(Buffer.from(k, "base64")); + + return { + admin: key( + // BnqzwtopjSGB9nHfMFYEa5p1kgeDBthfRP8yiLW9U7Kz + "6iMOkgS4ZAfVxUWOdzo8y+MDoRLfuX4oPbzQf8D2RuigU2i7DwWQ+x304o+/2aa0K695awmnbv1JfL+WWnDcPQ==" + ), + swapper: key( + // 8b3XbqeN3VrrNH3u1WvjK5BasW5hKWKVpDGz5tsq5CbL + "WmvqEmS7IwLjuBIMML4UY6d+VND9BnbG7B7Z4coApDdwumn5E6LBAS07RctOhxM5FXNEOizdNGuQwabX3Y/88w==" + ), + mMint: key( + // DfaRRLLVGYpfu33QdGHGLNKv2G4MyyMbmvGVpLQgFeeF + "VIC7l0xRw067AiX75WtZ2ehN3+1wIpGmH6gWvS4jvx+8LhpnI+sGHFi+6tair18K2nd6yr2IR97C5qZZYkkl5A==" + ), + extProgramA: key( + // 3joDhmLtHLrSBGfeAe1xQiv3gjikes3x8S4N3o6Ld8zB + "XVFfL68OjRO5g+DZxbQHXVqEtoU976BAS6y5RP905ZIorgcghvm5mrP60XsmiUAp4aSIBadFzWUK/bbBmqweYA==" + ), + extProgramB: key( + // HSMnbWEkB7sEQAGSzBPeACNUCXC9FgNeeESLnHtKfoy3 + "1y1p2+YND+xDi/CbPGRN7fiE08xzoD9Fd2vsLvH9r930OgOUua+nXoCRzIl9SRyiM5GyHki7EtaTGXT8mi3qmg==" + ), + mintA: key( + // GbfuJZa4zLNgxHCrXNTXzVZ3CPUCe5RYWPBq9bU9qekP + "UeJL1Qx6czzbwDTUjOHjKLJ7Ao4XJUGXlDC5vkbPC+znwQmrzy6AxwYGUH2VX4vVZHX8DQAq/sWauL1ucUZUaA==" + ), + mintB: key( + // 55H5CfmBxyaYnUhXxbToqT3FWhKMWmBJFrbd3WfuFy9u + "XmidpDRbKR+D56M2trCoiPRzi1yxKy8aUnO+p3PuLxA8hz59ZTmu51Bn9qFsZHxaIWi1tCUd5ibpOj4pBKqTVA==" + ), + multisig: key( + // FYvCWxAdFQYyJJPSXNKv2dzsdKq98EwmdRiu6rpY65gT + "CXioALq/oVhI/8QWp7AphKgZiJB1haG5kPomzHJ+n2jYMMYbLiWQ5knEMn9iu3T+5rn/YEs+M78sq5vOSwISWA==" + ), + extProgramC: key( + // 81gYpXqg8ZT9gdkFSe35eqiitqBWqVfYwDwVfXuk8Xfw + "YPIMBm2ykzl4I7GHdQyWqKwR9RJjiwNhDyOyWTHBjqdoLoa322EZkMKDzwDeycwd0vq3KrIs1ga19ecjWSJS0g==" + ), + mintC: key( + // H6V2ShFqjRaHyewiqaHN6E6ok1XRH2xv4Zwy3JpL8Cxb + "m+OGOQSbwMu+Io83qHWOFdPZsWxnFhaz0zwzFS6C0TDvIpUrxJFh8CBFOCAHmTJpK+I/1Zv1FOeqsykz2BPgDA==" + ), + }; +} diff --git a/tests/unit/ext_test_harness.ts b/tests/unit/ext_test_harness.ts new file mode 100644 index 0000000..765aa27 --- /dev/null +++ b/tests/unit/ext_test_harness.ts @@ -0,0 +1,1410 @@ +import { Program, AnchorError, BN } from "@coral-xyz/anchor"; +import { LiteSVM } from "litesvm"; +import { LiteSVMProvider } from "anchor-litesvm"; +import { + PublicKey, + Keypair, + LAMPORTS_PER_SOL, + SystemProgram, + Transaction, + TransactionInstruction, +} from "@solana/web3.js"; +import { + ACCOUNT_SIZE, + ASSOCIATED_TOKEN_PROGRAM_ID, + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, + createInitializeMintInstruction, + createInitializeImmutableOwnerInstruction, + createAssociatedTokenAccountInstruction, + createCloseAccountInstruction, + getAccount, + getAccountLen, + getMint, + getMintLen, + getMinimumBalanceForRentExemptMultisig, + getAssociatedTokenAddressSync, + createInitializeAccountInstruction, + createInitializeMultisigInstruction, + createMintToCheckedInstruction, + ExtensionType, + getExtensionData, + createApproveCheckedInstruction, +} from "@solana/spl-token"; +import { + Earn, + EARN_IDL, + PROGRAM_ID as EARN_PROGRAM_ID, + MerkleTree, + ProofElement, +} from "@m0-foundation/solana-m-sdk"; +import { + ZERO_WORD, + InitializeScaledUiAmountConfigInstructionData, + ScaledUiAmountConfig, + ScaledUiAmountConfigLayout, +} from "../test-utils"; + +import { MExt as ScaledUIExt } from "../../target/types/scaled_ui"; +import { MExt as NoYieldExt } from "../../target/types/no_yield"; +import { token } from "@coral-xyz/anchor/dist/cjs/utils"; + +export enum Comparison { + Equal, + GreaterThan, + GreaterThanOrEqual, + LessThan, + LessThanOrEqual, +} + +// Type definitions for accounts to make it easier to do comparisons + +export enum Variant { + ScaledUiAmount = "scaled_ui", + NoYield = "no_yield", +} + +type MExt = ScaledUIExt | NoYieldExt; + +export type YieldConfig = V extends Variant.ScaledUiAmount + ? { + feeBps?: BN; + lastMIndex?: BN; + lastExtIndex?: BN; + } + : {}; + +export type ExtGlobal = { + admin?: PublicKey; + extMint?: PublicKey; + mMint?: PublicKey; + mEarnGlobalAccount?: PublicKey; + bump?: number; + mVaultBump?: number; + extMintAuthorityBump?: number; + wrapAuthorities?: PublicKey[]; + yieldConfig?: YieldConfig; +}; + +const PROGRAM_ID = new PublicKey( + "3C865D264L4NkAm78zfnDzQJJvXuU3fMjRUvRxyPi5da" +); + +// Test harness for the MExt program that encapsulates all the necessary setup and helper functions to test a given program variant +export class ExtensionTest { + public variant: V; + public svm: LiteSVM; + public provider: LiteSVMProvider; + public accounts: Record = {}; + public earn: Program; + public ext: Program; + public admin: Keypair; + public mMint: Keypair; + public extMint: Keypair; + public mMintAuthority: Keypair; + public earnAuthority: Keypair; + public wrapAuthority: Keypair; + public nonAdmin: Keypair; + public nonWrapAuthority: Keypair; + public mEarnerList: PublicKey[] = []; + + constructor(variant: V, addresses: PublicKey[]) { + this.variant = variant; + const M_EXT_IDL = require(`../../target/idl/${variant}.json`); + + // Initialize the SVM instance with all necessary configurations + this.svm = new LiteSVM() + .withSplPrograms() // Add SPL programs (including token programs) + .withBuiltins() // Add builtin programs + .withSysvars() // Setup standard sysvars + .withPrecompiles() // Add standard precompiles + .withBlockhashCheck(true); // Optional: disable blockhash checking for tests + + // Add the earn program to the SVM instance + this.svm.addProgramFromFile(EARN_PROGRAM_ID, "tests/programs/earn.so"); + + // Replace the default token2022 program with the (newer) one from the workspace + this.svm.addProgramFromFile( + TOKEN_2022_PROGRAM_ID, + "tests/programs/spl_token_2022.so" + ); + + // Add the ext program to the SVM instance + this.svm.addProgramFromFile(PROGRAM_ID, `target/deploy/${variant}.so`); + + // Create an anchor provider from the liteSVM instance + this.provider = new LiteSVMProvider(this.svm); + + // Create program instances + this.earn = new Program(EARN_IDL, this.provider); + this.ext = new Program(M_EXT_IDL, this.provider); + + // Generate keypairs for various roles and fund them + this.admin = new Keypair(); + this.mMint = new Keypair(); + this.extMint = new Keypair(); + this.mMintAuthority = new Keypair(); + this.earnAuthority = new Keypair(); + this.wrapAuthority = new Keypair(); + this.nonAdmin = new Keypair(); + this.nonWrapAuthority = new Keypair(); + + addresses = addresses.concat([ + this.admin.publicKey, + this.earnAuthority.publicKey, + this.wrapAuthority.publicKey, + this.nonAdmin.publicKey, + this.nonWrapAuthority.publicKey, + ]); + + for (const address of addresses) { + this.svm.airdrop(address, BigInt(10 * LAMPORTS_PER_SOL)); + } + } + + public async init(initialSupply: BN, initialIndex: BN, claimCooldown: BN) { + // Create the M token mint + await this.createMintWithMultisig(this.mMint, this.mMintAuthority); + + // Create the Ext token mint + switch (this.variant) { + case Variant.ScaledUiAmount: + await this.createScaledUiMint(this.extMint, this.getExtMintAuthority()); + break; + case Variant.NoYield: + await this.createMint(this.extMint, this.getExtMintAuthority()); + break; + default: + throw new Error("Unsupported variant for MExt"); + } + + // Mint some m tokens to have a non-zero supply + await this.mintM(this.admin.publicKey, initialSupply); + + // Initialize the earn program + await this.initializeEarn( + this.mMint.publicKey, + this.earnAuthority.publicKey, + initialIndex, + claimCooldown + ); + + // Add the m vault as an M earner + const mVault = this.getMVault(); + await this.addMEarner(mVault); + } + + // Helper functions for token operations and checks on the SVM instance + public async expectTokenBalance( + tokenAccount: PublicKey, + expectedBalance: BN, + op: Comparison = Comparison.Equal, + tolerance?: BN + ) { + const balance = ( + await getAccount( + this.provider.connection, + tokenAccount, + undefined, + TOKEN_2022_PROGRAM_ID + ) + ).amount; + + switch (op) { + case Comparison.GreaterThan: + expect(balance).toBeGreaterThan(BigInt(expectedBalance.toString())); + if (tolerance) { + expect(balance).toBeLessThanOrEqual( + BigInt(expectedBalance.add(tolerance).toString()) + ); + } + break; + case Comparison.GreaterThanOrEqual: + expect(balance).toBeGreaterThanOrEqual( + BigInt(expectedBalance.toString()) + ); + if (tolerance) { + expect(balance).toBeLessThanOrEqual( + BigInt(expectedBalance.add(tolerance).toString()) + ); + } + break; + case Comparison.LessThan: + expect(balance).toBeLessThan(BigInt(expectedBalance.toString())); + if (tolerance) { + expect(balance).toBeGreaterThanOrEqual( + BigInt(expectedBalance.sub(tolerance).toString()) + ); + } + break; + case Comparison.LessThanOrEqual: + expect(balance).toBeLessThanOrEqual(BigInt(expectedBalance.toString())); + if (tolerance) { + expect(balance).toBeGreaterThanOrEqual( + BigInt(expectedBalance.sub(tolerance).toString()) + ); + } + break; + default: + if (tolerance) { + expect(balance).toBeGreaterThanOrEqual( + BigInt(expectedBalance.sub(tolerance).toString()) + ); + expect(balance).toBeLessThanOrEqual( + BigInt(expectedBalance.add(tolerance).toString()) + ); + } else { + expect(balance).toEqual(BigInt(expectedBalance.toString())); + } + break; + } + } + + public async expectTokenUiBalance( + tokenAccount: PublicKey, + expectedBalance: BN, + op: Comparison = Comparison.Equal, + tolerance?: BN + ) { + const rawBalance = ( + await getAccount( + this.provider.connection, + tokenAccount, + undefined, + TOKEN_2022_PROGRAM_ID + ) + ).amount; + + const multiplier = ( + await this.getScaledUiAmountConfig(this.extMint.publicKey) + ).multiplier; + + const scale = 1e12; + + const uiBalance = + (rawBalance * BigInt(Math.floor(multiplier * scale))) / BigInt(scale); + + switch (op) { + case Comparison.GreaterThan: + expect(uiBalance).toBeGreaterThan(BigInt(expectedBalance.toString())); + if (tolerance) { + expect(uiBalance).toBeLessThanOrEqual( + BigInt(expectedBalance.add(tolerance).toString()) + ); + } + break; + case Comparison.GreaterThanOrEqual: + expect(uiBalance).toBeGreaterThanOrEqual( + BigInt(expectedBalance.toString()) + ); + if (tolerance) { + expect(uiBalance).toBeLessThanOrEqual( + BigInt(expectedBalance.add(tolerance).toString()) + ); + } + break; + case Comparison.LessThan: + expect(uiBalance).toBeLessThan(BigInt(expectedBalance.toString())); + if (tolerance) { + expect(uiBalance).toBeGreaterThanOrEqual( + BigInt(expectedBalance.sub(tolerance).toString()) + ); + } + break; + case Comparison.LessThanOrEqual: + expect(uiBalance).toBeLessThanOrEqual( + BigInt(expectedBalance.toString()) + ); + if (tolerance) { + expect(uiBalance).toBeGreaterThanOrEqual( + BigInt(expectedBalance.sub(tolerance).toString()) + ); + } + break; + default: + if (tolerance) { + expect(uiBalance).toBeGreaterThanOrEqual( + BigInt(expectedBalance.sub(tolerance).toString()) + ); + expect(uiBalance).toBeLessThanOrEqual( + BigInt(expectedBalance.add(tolerance).toString()) + ); + } else { + expect(uiBalance).toEqual(BigInt(expectedBalance.toString())); + } + break; + } + } + + public async createATA( + mint: PublicKey, + owner: PublicKey, + use2022: boolean = true + ) { + const tokenAccount = getAssociatedTokenAddressSync( + mint, + owner, + true, + use2022 ? TOKEN_2022_PROGRAM_ID : TOKEN_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID + ); + + const createATA = createAssociatedTokenAccountInstruction( + this.admin.publicKey, // payer + tokenAccount, // ata + owner, // owner + mint, // mint + use2022 ? TOKEN_2022_PROGRAM_ID : TOKEN_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID + ); + + let tx = new Transaction().add(createATA); + + await this.provider.sendAndConfirm!(tx, [this.admin]); + + return tokenAccount; + } + + public async getATA( + mint: PublicKey, + owner: PublicKey, + use2022: boolean = true + ) { + // Check to see if the ATA already exists, if so return its key + const tokenAccount = getAssociatedTokenAddressSync( + mint, + owner, + true, + use2022 ? TOKEN_2022_PROGRAM_ID : TOKEN_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID + ); + + const tokenAccountInfo = this.svm.getAccount(tokenAccount); + + if (!tokenAccountInfo) { + await this.createATA(mint, owner, use2022); + } + + return tokenAccount; + } + + public async createTokenAccount( + mint: PublicKey, + owner: PublicKey, + use2022: boolean = true, + immutableOwner: boolean = false + ) { + // We want to create a token account that is not the ATA + const tokenAccount = new Keypair(); + const tokenAccountLen = + use2022 && immutableOwner + ? getAccountLen([ExtensionType.ImmutableOwner]) + : ACCOUNT_SIZE; + + let ixs: TransactionInstruction[] = []; + ixs.push( + SystemProgram.createAccount({ + fromPubkey: this.admin.publicKey, + newAccountPubkey: tokenAccount.publicKey, + space: tokenAccountLen, + lamports: + await this.provider.connection.getMinimumBalanceForRentExemption( + tokenAccountLen + ), + programId: use2022 ? TOKEN_2022_PROGRAM_ID : TOKEN_PROGRAM_ID, + }) + ); + if (use2022 && immutableOwner) { + ixs.push( + createInitializeImmutableOwnerInstruction( + tokenAccount.publicKey, + TOKEN_2022_PROGRAM_ID + ) + ); + } + ixs.push( + createInitializeAccountInstruction( + tokenAccount.publicKey, + mint, + owner, + use2022 ? TOKEN_2022_PROGRAM_ID : TOKEN_PROGRAM_ID + ) + ); + + let tx = new Transaction(); + tx.add(...ixs); + + await this.provider.sendAndConfirm!(tx, [this.admin, tokenAccount]); + + return { tokenAccount: tokenAccount.publicKey }; + } + + public async closeTokenAccount(owner: Keypair, tokenAccount: PublicKey) { + const closeIx = createCloseAccountInstruction( + tokenAccount, + owner.publicKey, + owner.publicKey, + [], + TOKEN_2022_PROGRAM_ID + ); + + let tx = new Transaction().add(closeIx); + + await this.provider.sendAndConfirm!(tx, [owner]); + } + + public async createMint( + mint: Keypair, + mintAuthority: PublicKey, + use2022: boolean = true, + decimals = 6, + freezeAuthority: boolean = true + ) { + // Create and initialize mint account + + const tokenProgram = use2022 ? TOKEN_2022_PROGRAM_ID : TOKEN_PROGRAM_ID; + + const mintLen = getMintLen([]); + 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 initializeMint = createInitializeMintInstruction( + mint.publicKey, + decimals, // decimals + mintAuthority, // mint authority + freezeAuthority ? mintAuthority : null, // freeze authority + tokenProgram + ); + + let tx = new Transaction(); + tx.add(createMintAccount, initializeMint); + + await this.provider.sendAndConfirm!(tx, [this.admin, mint]); + + // Verify the mint was created properly + const mintInfo = await this.provider.connection.getAccountInfo( + mint.publicKey + ); + if (!mintInfo) { + throw new Error("Mint account was not created"); + } + + return mint.publicKey; + } + + public createInitializeScaledUiAmountConfigInstruction( + mint: PublicKey, + authority: PublicKey | null, + multiplier: number, + programId: PublicKey = TOKEN_2022_PROGRAM_ID + ): TransactionInstruction { + const keys = [{ pubkey: mint, isSigner: false, isWritable: true }]; + + const data = Buffer.alloc( + InitializeScaledUiAmountConfigInstructionData.span + ); + InitializeScaledUiAmountConfigInstructionData.encode( + { + instruction: 43, // scaled ui amount extension + scaledUiAmountInstruction: 0, // initialize + authority: authority ?? PublicKey.default, + multiplier: multiplier, + }, + data + ); + + return new TransactionInstruction({ keys, programId, data }); + } + + public async createScaledUiMint( + mint: Keypair, + mintAuthority: PublicKey, + decimals = 6 + ) { + // Create and initialize mint account + + const tokenProgram = TOKEN_2022_PROGRAM_ID; + + const mintLen = getMintLen([ExtensionType.ScaledUiAmountConfig]); + 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 initializeScaledUiAmountConfig = + this.createInitializeScaledUiAmountConfigInstruction( + mint.publicKey, + mintAuthority, + 1.0, + tokenProgram + ); + + const initializeMint = createInitializeMintInstruction( + mint.publicKey, + decimals, // decimals + mintAuthority, // mint authority + mintAuthority, // freeze authority + tokenProgram + ); + + let tx = new Transaction(); + tx.add(createMintAccount, initializeScaledUiAmountConfig, initializeMint); + + await this.provider.sendAndConfirm!(tx, [this.admin, mint]); + + // Verify the mint was created properly + const mintInfo = await this.provider.connection.getAccountInfo( + mint.publicKey + ); + if (!mintInfo) { + throw new Error("Mint account was not created"); + } + + return mint.publicKey; + } + + public async getScaledUiAmountConfig( + mint: PublicKey + ): Promise { + const mintAccount = await getMint( + this.provider.connection, + mint, + undefined, + TOKEN_2022_PROGRAM_ID + ); + const extensionData = getExtensionData( + ExtensionType.ScaledUiAmountConfig, + mintAccount.tlvData + ); + if (extensionData === null) { + throw new Error("Extension data not found"); + } + + return ScaledUiAmountConfigLayout.decode(extensionData); + } + + public async createMintWithMultisig(mint: Keypair, mintAuthority: Keypair) { + // Create and initialize multisig mint authority on the token program + const multisigLen = 355; + // const multisigLamports = await provider.connection.getMinimumBalanceForRentExemption(multisigLen); + const multisigLamports = await getMinimumBalanceForRentExemptMultisig( + this.provider.connection + ); + + const createMultisigAccount = SystemProgram.createAccount({ + fromPubkey: this.admin.publicKey, + newAccountPubkey: mintAuthority.publicKey, + space: multisigLen, + lamports: multisigLamports, + programId: TOKEN_2022_PROGRAM_ID, + }); + + const earnTokenAuthority = this.getEarnTokenAuthority(); + + const initializeMultisig = createInitializeMultisigInstruction( + mintAuthority.publicKey, // account + [this.admin, earnTokenAuthority], + 1, + TOKEN_2022_PROGRAM_ID + ); + + let tx = new Transaction(); + tx.add(createMultisigAccount, initializeMultisig); + + await this.provider.sendAndConfirm!(tx, [this.admin, mintAuthority]); + + // Create and initialize mint account + + const mintLen = getMintLen([]); + const mintLamports = + await this.provider.connection.getMinimumBalanceForRentExemption(mintLen); + const createMintWithMultisigAccount = SystemProgram.createAccount({ + fromPubkey: this.admin.publicKey, + newAccountPubkey: mint.publicKey, + space: mintLen, + lamports: mintLamports, + programId: TOKEN_2022_PROGRAM_ID, + }); + + const initializeMint = createInitializeMintInstruction( + mint.publicKey, + 6, // decimals + mintAuthority.publicKey, // mint authority + null, // freeze authority + TOKEN_2022_PROGRAM_ID + ); + + tx = new Transaction(); + tx.add(createMintWithMultisigAccount, initializeMint); + + await this.provider.sendAndConfirm!(tx, [this.admin, mint]); + + // Verify the mint was created properly + const mintInfo = await this.provider.connection.getAccountInfo( + mint.publicKey + ); + if (!mintInfo) { + throw new Error("Mint account was not created"); + } + + return mint.publicKey; + } + + public async mintM(to: PublicKey, amount: BN) { + const toATA: PublicKey = await this.getATA(this.mMint.publicKey, to); + + const mintToInstruction = createMintToCheckedInstruction( + this.mMint.publicKey, + toATA, + this.mMintAuthority.publicKey, + BigInt(amount.toString()), + 6, + [this.admin], + TOKEN_2022_PROGRAM_ID + ); + + let tx = new Transaction(); + tx.add(mintToInstruction); + await this.provider.sendAndConfirm!(tx, [this.admin]); + } + + public async getTokenBalance(tokenAccount: PublicKey) { + const tokenAccountInfo = await getAccount( + this.provider.connection, + tokenAccount, + undefined, + TOKEN_2022_PROGRAM_ID + ); + if (!tokenAccountInfo) { + throw new Error("Account not created"); + } + + return new BN(tokenAccountInfo.amount.toString()); + } + + public async getTokenUiBalance(tokenAccount: PublicKey, multiplier?: number) { + const tokenAccountInfo = await getAccount( + this.provider.connection, + tokenAccount, + undefined, + TOKEN_2022_PROGRAM_ID + ); + + if (!tokenAccountInfo) { + throw new Error("Account not created"); + } + + const mp = + multiplier ?? + (await this.getScaledUiAmountConfig(tokenAccountInfo.mint)).multiplier; + + const scale = 1e12; + + const uiBalance = + (tokenAccountInfo.amount * BigInt(Math.floor(mp * scale))) / + BigInt(scale); + + return new BN(uiBalance.toString()); + } + + public async getTokenSupply(mint: PublicKey) { + const mintInfo = await getMint( + this.provider.connection, + mint, + undefined, + TOKEN_2022_PROGRAM_ID + ); + if (!mintInfo) { + throw new Error("Mint not found"); + } + + return new BN( + Math.floor(Number(mintInfo.supply) * (await this.getCurrentMultiplier())) + ); + } + + public async approve( + source: Keypair, + delegate: PublicKey, + mint: PublicKey, + amount: BN + ) { + const sourceATA: PublicKey = await this.getATA(mint, source.publicKey); + + const approveIx = createApproveCheckedInstruction( + sourceATA, + mint, + delegate, + source.publicKey, + BigInt(amount.toString()), + 6, // decimals + [], + TOKEN_2022_PROGRAM_ID + ); + + let tx = new Transaction(); + tx.add(approveIx); + await this.provider.sendAndConfirm!(tx, [source]); + + return { sourceATA }; + } + + // general SVM cheat functions + public warp(seconds: BN, increment: boolean) { + const clock = this.svm.getClock(); + clock.unixTimestamp = increment + ? clock.unixTimestamp + BigInt(seconds.toString()) + : BigInt(seconds.toString()); + this.svm.setClock(clock); + } + + public currentTime(): BN { + return new BN(this.svm.getClock().unixTimestamp.toString()); + } + + // Helper functions for Earn and MExt program PDAs + public getEarnGlobalAccount(): PublicKey { + const [globalAccount] = PublicKey.findProgramAddressSync( + [Buffer.from("global")], + this.earn.programId + ); + + return globalAccount; + } + + public getEarnTokenAuthority(): PublicKey { + const [earnTokenAuthority] = PublicKey.findProgramAddressSync( + [Buffer.from("token_authority")], + this.earn.programId + ); + + return earnTokenAuthority; + } + + public getExtGlobalAccount(): PublicKey { + const [globalAccount] = PublicKey.findProgramAddressSync( + [Buffer.from("global")], + this.ext.programId + ); + + return globalAccount; + } + + public getExtMintAuthority(): PublicKey { + const [extMintAuthority] = PublicKey.findProgramAddressSync( + [Buffer.from("mint_authority")], + this.ext.programId + ); + + return extMintAuthority; + } + + public getMVault(): PublicKey { + const [mVault] = PublicKey.findProgramAddressSync( + [Buffer.from("m_vault")], + this.ext.programId + ); + + return mVault; + } + + public getMEarnerAccount(tokenAccount: PublicKey): PublicKey { + const [earnerAccount] = PublicKey.findProgramAddressSync( + [Buffer.from("earner"), tokenAccount.toBuffer()], + this.earn.programId + ); + + return earnerAccount; + } + + public async getNewMultiplier(newIndex: BN): Promise { + if (this.variant === Variant.NoYield) { + return 1.0; + } + + const yieldConfig: YieldConfig = ( + await this.ext.account.extGlobal.fetch(this.getExtGlobalAccount()) + ).yieldConfig; + + return ( + (yieldConfig.lastExtIndex!.toNumber() / 1e12) * + (newIndex.toNumber() / yieldConfig.lastMIndex!.toNumber()) ** + (1 - yieldConfig.feeBps!.toNumber() / 1e4) + ); + } + + public async getCurrentMultiplier(): Promise { + if (this.variant === Variant.NoYield) { + return 1.0; + } + + const yieldConfig: YieldConfig = ( + await this.ext.account.extGlobal.fetch(this.getExtGlobalAccount()) + ).yieldConfig; + + return yieldConfig.lastExtIndex!.toNumber() / 1e12; + } + + // Utility functions for the tests + public expectAccountEmpty(account: PublicKey) { + const accountInfo = this.svm.getAccount(account); + + if (accountInfo) { + expect(accountInfo.lamports).toBe(0); + expect(accountInfo.data.length).toBe(0); + expect(accountInfo.owner).toStrictEqual(SystemProgram.programId); + } + } + + public async expectAnchorError(txResult: Promise, errCode: string) { + try { + await txResult; + throw new Error("Transaction should have reverted"); + } catch (e) { + if (!(e instanceof AnchorError)) + throw new Error(`Expected AnchorError, got ${e}`); + const err: AnchorError = e; + expect(err.error.errorCode.code).toStrictEqual(errCode); + } + } + + public async expectSystemError(txResult: Promise) { + let reverted = false; + try { + await txResult; + } catch (e) { + // console.log(e.transactionMessage); + // console.log(e.logs); + reverted = true; + } finally { + expect(reverted).toBe(true); + } + } + + public async expectExtGlobalState(expected: ExtGlobal) { + const state = await this.ext.account.extGlobal.fetch( + this.getExtGlobalAccount() + ); + + if (expected.admin) expect(state.admin).toEqual(expected.admin); + if (expected.extMint) expect(state.extMint).toEqual(expected.extMint); + if (expected.mMint) expect(state.mMint).toEqual(expected.mMint); + if (expected.mEarnGlobalAccount) + expect(state.mEarnGlobalAccount).toEqual(expected.mEarnGlobalAccount); + + if (expected.yieldConfig) { + switch (this.variant) { + case Variant.ScaledUiAmount: + this.expectScaledUiYieldConfig(state.yieldConfig); + break; + case Variant.NoYield: + expect(state.yieldConfig).toEqual({}); + break; + default: + throw new Error("Unsupported variant for yield config"); + } + } + if (expected.bump) expect(state.bump).toEqual(expected.bump); + if (expected.mVaultBump) + expect(state.mVaultBump).toEqual(expected.mVaultBump); + if (expected.extMintAuthorityBump) + expect(state.extMintAuthorityBump).toEqual(expected.extMintAuthorityBump); + } + + private expectScaledUiYieldConfig( + yieldConfig: YieldConfig + ) { + if (yieldConfig.feeBps) { + expect(yieldConfig.feeBps.toString()).toEqual( + yieldConfig.feeBps.toString() + ); + } + if (yieldConfig.lastMIndex) { + expect(yieldConfig.lastMIndex.toString()).toEqual( + yieldConfig.lastMIndex.toString() + ); + } + if (yieldConfig.lastExtIndex) { + expect(yieldConfig.lastExtIndex.toString()).toEqual( + yieldConfig.lastExtIndex.toString() + ); + } + } + + public async expectScaledUiAmountConfig( + mint: PublicKey, + expected: ScaledUiAmountConfig + ) { + const state = await this.getScaledUiAmountConfig(mint); + + if (expected.authority) expect(state.authority).toEqual(expected.authority); + if (expected.multiplier) { + // account for javascript vs. rust floating point precision differences + const exp_high = (Math.floor(expected.multiplier * 1e12) + 1) / 1e12; + const exp_low = (Math.floor(expected.multiplier * 1e12) - 1) / 1e12; + + expect(state.multiplier).toBeGreaterThanOrEqual(exp_low); + expect(state.multiplier).toBeLessThanOrEqual(exp_high); + } + if (expected.newMultiplierEffectiveTimestamp) + expect(state.newMultiplierEffectiveTimestamp.toString()).toEqual( + expected.newMultiplierEffectiveTimestamp.toString() + ); + if (expected.newMultiplier) { + // account for javascript vs. rust floating point precision differences + const exp_high = (Math.floor(expected.newMultiplier * 1e12) + 1) / 1e12; + const exp_low = (Math.floor(expected.newMultiplier * 1e12) - 1) / 1e12; + + expect(state.newMultiplier).toBeGreaterThanOrEqual(exp_low); + expect(state.newMultiplier).toBeLessThanOrEqual(exp_high); + } + } + + createUniqueKeyArray = (size: number) => { + return new Array(size).fill(PublicKey.default).map((_, i, arr) => { + let key = PublicKey.unique(); + while (key.equals(PublicKey.default) || arr.includes(key)) { + key = PublicKey.unique(); + } + return key; + }); + }; + + public async expectExtSolvent() { + const extSupply = await this.getTokenSupply(this.extMint.publicKey); + const mVaultBalance = await this.getTokenBalance( + await this.getATA(this.mMint.publicKey, this.getMVault()) + ); + + this.variant === Variant.ScaledUiAmount + ? expect(BigInt(mVaultBalance.toString())).toBeGreaterThan( + BigInt(extSupply.sub(BN.min(new BN(2), extSupply)).toString()) + ) // allow for a rounding error of 2 for scaled ui due to precision issues + : expect(BigInt(mVaultBalance.toString())).toBeGreaterThanOrEqual( + BigInt(extSupply.toString()) + ); + } + + padKeyArray = (array: PublicKey[], desiredLen: number) => { + const currentLen = array.length; + + if (currentLen > desiredLen) { + throw new Error("Array is too long"); + } + + const padding = new Array(desiredLen - currentLen).fill(PublicKey.default); + return array.concat(padding); + }; + + // instruction convenience functions for earn program + + public async initializeEarn( + mint: PublicKey, + earnAuthority: PublicKey, + initialIndex: BN, + claimCooldown: BN + ) { + // Send the transaction + try { + await this.earn.methods + .initialize(earnAuthority, initialIndex, claimCooldown) + .accounts({ + admin: this.admin.publicKey, + mint, + }) + .signers([this.admin]) + .rpc(); + } catch (e) { + console.log(e); + throw e; + } + } + + public async propagateIndex( + newIndex: BN, + earnerMerkleRoot: number[] = ZERO_WORD + ) { + // Send the instruction + await this.earn.methods + .propagateIndex(newIndex, earnerMerkleRoot) + .accounts({ + signer: this.admin.publicKey, + }) + .signers([this.admin]) + .rpc(); + } + + public async mClaimFor(earner: PublicKey, balance?: BN) { + const earnerATA = await this.getATA(this.mMint.publicKey, earner); + const earnerAccount = this.getMEarnerAccount(earnerATA); + const snapshotBalance = balance ?? (await this.getTokenBalance(earnerATA)); + + // Send the instruction + await this.earn.methods + .claimFor(snapshotBalance) + .accounts({ + earnAuthority: this.earnAuthority.publicKey, + mint: this.mMint.publicKey, + mintMultisig: this.mMintAuthority.publicKey, + userTokenAccount: earnerATA, + earnerAccount, + tokenProgram: TOKEN_2022_PROGRAM_ID, + }) + .signers([this.earnAuthority]) + .rpc(); + } + + public async mCompleteClaims() { + // Send the instruction + await this.earn.methods + .completeClaims() + .accounts({ + earnAuthority: this.earnAuthority.publicKey, + }) + .signers([this.earnAuthority]) + .rpc(); + } + + async addRegistrarEarner( + earner: PublicKey, + proof: ProofElement[], + earnerTokenAccount?: PublicKey + ) { + // Get the earner ATA + const tokenAccount = + earnerTokenAccount ?? (await this.getATA(this.mMint.publicKey, earner)); + + // Send the instruction + await this.earn.methods + .addRegistrarEarner(earner, proof) + .accountsPartial({ + signer: this.nonAdmin.publicKey, + userTokenAccount: tokenAccount, + }) + .signers([this.nonAdmin]) + .rpc(); + } + + async removeRegistrarEarner( + earner: PublicKey, + proofs: ProofElement[][], + neighbors: PublicKey[], + earnerTokenAccount?: PublicKey + ) { + // Get the earner ATA + const tokenAccount = + earnerTokenAccount ?? (await this.getATA(this.mMint.publicKey, earner)); + const earnerAccount = this.getMEarnerAccount(tokenAccount); + + // Send the instruction + await this.earn.methods + .removeRegistrarEarner(proofs, neighbors) + .accountsPartial({ + signer: this.nonAdmin.publicKey, + userTokenAccount: tokenAccount, + earnerAccount, + }) + .signers([this.nonAdmin]) + .rpc(); + } + + public async addMEarner( + earner: PublicKey, + earnerTokenAccount?: PublicKey + ): Promise { + // Check that the earner is not already in the list + if (this.mEarnerList.map((e) => e.toBase58()).includes(earner.toBase58())) { + throw new Error("Earner already exists in the list"); + } + + // Add the earner to the list and get the merkle tree + this.mEarnerList.push(earner); + const earnerMerkleTree = new MerkleTree(this.mEarnerList); + + // Get the current index to reuse + const currentIndex = ( + await this.earn.account.global.fetch(this.getEarnGlobalAccount()) + ).index; + + // Propagate the merkle root + await this.propagateIndex(currentIndex, earnerMerkleTree.getRoot()); + + // Create the earner token for the m token + const tokenAccount = + earnerTokenAccount ?? (await this.getATA(this.mMint.publicKey, earner)); + + // Add the earner to the earn program + const { proof } = earnerMerkleTree.getInclusionProof(earner); + await this.addRegistrarEarner(earner, proof, tokenAccount); + + // Get the earner account address + const earnerAccount = this.getMEarnerAccount(tokenAccount); + + return earnerAccount; + } + + public async removeMEarner( + earner: PublicKey, + earnerTokenAccount?: PublicKey + ): Promise { + // Check that the earner is in the list + if ( + !this.mEarnerList.map((e) => e.toBase58()).includes(earner.toBase58()) + ) { + throw new Error("Earner does not exist in the list"); + } + + // Remove the earner from the list and get the merkle tree + this.mEarnerList = this.mEarnerList.filter((e) => !e.equals(earner)); + const earnerMerkleTree = new MerkleTree(this.mEarnerList); + + // Get the current index to reuse + const currentIndex = ( + await this.earn.account.global.fetch(this.getEarnGlobalAccount()) + ).index; + + // Propagate the merkle root + await this.propagateIndex(currentIndex, earnerMerkleTree.getRoot()); + + // Get the earner token account + const tokenAccount = + earnerTokenAccount ?? (await this.getATA(this.mMint.publicKey, earner)); + + // Remove the earner from the earn program + const { proofs, neighbors } = earnerMerkleTree.getExclusionProof(earner); + await this.removeRegistrarEarner(earner, proofs, neighbors, tokenAccount); + } + // Helper functions for executing MExt instructions + + public async initializeExt(wrapAuthorities: PublicKey[], fee_bps?: BN) { + switch (this.variant) { + case Variant.ScaledUiAmount: + if (!fee_bps) { + throw new Error("fee_bps is required for Scaled UI variant"); + } + // Send the transaction + await this.ext.methods + .initialize(wrapAuthorities, fee_bps) + .accounts({ + admin: this.admin.publicKey, + mMint: this.mMint.publicKey, + extMint: this.extMint.publicKey, + }) + .signers([this.admin]) + .rpc(); + break; + case Variant.NoYield: + // Send the transaction + await this.ext.methods + .initialize(wrapAuthorities) + .accounts({ + admin: this.admin.publicKey, + mMint: this.mMint.publicKey, + extMint: this.extMint.publicKey, + }) + .signers([this.admin]) + .rpc(); + break; + default: + throw new Error("Unsupported variant for initializeExt"); + } + } + + public async setMMint(mint: PublicKey) { + // Send the instruction + await this.ext.methods + .setMMint() + .accounts({ + newMMint: mint, + }) + .signers([this.admin]) + .rpc(); + } + + public async addWrapAuthority(newWrapAuthority: PublicKey) { + // Send the instruction + await this.ext.methods + .addWrapAuthority(newWrapAuthority) + .accounts({}) + .signers([this.admin]) + .rpc(); + } + + public async removeWrapAuthority(oldWrapAuthority: PublicKey) { + // Send the instruction + await this.ext.methods + .removeWrapAuthority(oldWrapAuthority) + .accounts({}) + .signers([this.admin]) + .rpc(); + } + + public async prepWrap( + from: PublicKey, + to?: PublicKey, + fromMTokenAccount?: PublicKey, + toExtTokenAccount?: PublicKey, + vaultMTokenAccount?: PublicKey + ): Promise<{ + vaultMTokenAccount: PublicKey; + fromMTokenAccount: PublicKey; + toExtTokenAccount: PublicKey; + }> { + // Get m vault pda + const mVault = this.getMVault(); + + // Create accounts if needed + fromMTokenAccount = + fromMTokenAccount ?? (await this.getATA(this.mMint.publicKey, from)); + toExtTokenAccount = + toExtTokenAccount ?? + (await this.getATA(this.extMint.publicKey, to ?? from)); + vaultMTokenAccount = + vaultMTokenAccount ?? (await this.getATA(this.mMint.publicKey, mVault)); + + return { + vaultMTokenAccount, + fromMTokenAccount, + toExtTokenAccount, + }; + } + + public async wrap( + tokenAuthority: Keypair, + amount: BN, + wrapAuthority?: Keypair | null, + from?: PublicKey, + to?: PublicKey + ): Promise<{ + vaultMTokenAccount: PublicKey; + fromMTokenAccount: PublicKey; + toExtTokenAccount: PublicKey; + }> { + // Setup the instruction + const { vaultMTokenAccount, fromMTokenAccount, toExtTokenAccount } = + await this.prepWrap(from ?? tokenAuthority.publicKey, to); + + // Send the instruction + await this.ext.methods + .wrap(amount) + .accounts({ + tokenAuthority: tokenAuthority.publicKey, + wrapAuthority: wrapAuthority + ? wrapAuthority.publicKey + : this.ext.programId, + fromMTokenAccount, + toExtTokenAccount, + }) + .signers( + wrapAuthority ? [tokenAuthority, wrapAuthority] : [tokenAuthority] + ) + .rpc(); + + return { vaultMTokenAccount, fromMTokenAccount, toExtTokenAccount }; + } + + public async prepUnwrap( + from: PublicKey, + to?: PublicKey, + toMTokenAccount?: PublicKey, + fromExtTokenAccount?: PublicKey, + vaultMTokenAccount?: PublicKey + ): Promise<{ + vaultMTokenAccount: PublicKey; + toMTokenAccount: PublicKey; + fromExtTokenAccount: PublicKey; + }> { + // Get m vault pda + const mVault = this.getMVault(); + + toMTokenAccount = + toMTokenAccount ?? (await this.getATA(this.mMint.publicKey, to ?? from)); + fromExtTokenAccount = + fromExtTokenAccount ?? (await this.getATA(this.extMint.publicKey, from)); + vaultMTokenAccount = + vaultMTokenAccount ?? (await this.getATA(this.mMint.publicKey, mVault)); + + return { + vaultMTokenAccount, + toMTokenAccount, + fromExtTokenAccount, + }; + } + + public async unwrap( + tokenAuthority: Keypair, + amount: BN, + wrapAuthority?: Keypair | null, + from?: PublicKey, + to?: PublicKey + ): Promise<{ + vaultMTokenAccount: PublicKey; + toMTokenAccount: PublicKey; + fromExtTokenAccount: PublicKey; + }> { + // Setup the instruction + const { vaultMTokenAccount, toMTokenAccount, fromExtTokenAccount } = + await this.prepUnwrap(from ?? tokenAuthority.publicKey, to); + + // Send the instruction + await this.ext.methods + .unwrap(amount) + .accounts({ + tokenAuthority: tokenAuthority.publicKey, + unwrapAuthority: wrapAuthority + ? wrapAuthority.publicKey + : this.ext.programId, + toMTokenAccount, + fromExtTokenAccount, + }) + .signers( + wrapAuthority ? [tokenAuthority, wrapAuthority] : [tokenAuthority] + ) + .rpc(); + + return { vaultMTokenAccount, toMTokenAccount, fromExtTokenAccount }; + } + + public async sync(): Promise { + if (this.variant === Variant.NoYield) { + throw new Error("sync is not supported for No Yield variant"); + } + + // Send the instruction + await this.ext.methods.sync().accounts({}).signers([]).rpc(); + + return this.getExtGlobalAccount(); + } + + public async claimFees( + toTokenAccount?: PublicKey + ): Promise<{ recipientExtTokenAccount: PublicKey }> { + const recipientExtTokenAccount = + toTokenAccount ?? + (await this.getATA(this.extMint.publicKey, this.admin.publicKey, true)); + + // Send the instruction + await this.ext.methods + .claimFees() + .accountsPartial({ + admin: this.admin.publicKey, + recipientExtTokenAccount, + }) + .signers([this.admin]) + .rpc(); + + return { recipientExtTokenAccount }; + } +} diff --git a/tests/unit/m_ext.test.ts b/tests/unit/m_ext.test.ts new file mode 100644 index 0000000..f889352 --- /dev/null +++ b/tests/unit/m_ext.test.ts @@ -0,0 +1,4008 @@ +import { BN } from "@coral-xyz/anchor"; +import { + PublicKey, + Keypair, + Transaction, + SystemProgram, +} from "@solana/web3.js"; +import { + TOKEN_2022_PROGRAM_ID, + createMintToCheckedInstruction, + getMint, +} from "@solana/spl-token"; +import { randomInt } from "crypto"; + +import { Comparison, ExtensionTest, Variant } from "./ext_test_harness"; +import { padKeyArray } from "../test-utils"; + +// Unit tests for ext earn program + +// Start parameters for M Earn +const initialSupply = new BN(100_000_000); // 100 tokens with 6 decimals +const initialIndex = new BN(1_100_000_000_000); // 1.1 +const claimCooldown = new BN(0); // None + +const VARIANTS: Variant[] = [Variant.ScaledUiAmount, Variant.NoYield]; + +// Implement test cases for all variants +// Most are the same, but allows conditional tests when required for different variants +for (const variant of VARIANTS) { + let $: ExtensionTest; + + describe(`${variant} unit tests`, () => { + beforeEach(async () => { + // Create new extenstion test harness and then initialize it + $ = new ExtensionTest(variant, []); + await $.init(initialSupply, initialIndex, claimCooldown); + }); + + describe("admin instruction tests", () => { + describe("initialize unit tests", () => { + // general test cases + // [X] given the m_mint is not owned by the token2022 program + // [X] it reverts with a ConstraintAddress error + // [X] given the ext_mint is not owned by the token2022 program + // [X] it reverts with a ConstraintMintTokenProgram error + // [X] given the ext_mint decimals do not match the m_mint decimals + // [X] it reverts with a ConstraintMintDecimals error + // [X] given the M earn global account does not match the PDA on the earn program + // [X] it reverts with a SeedsConstraint error + // [X] given the m_earner_account is not the required PDA + // [X] it reverts with a SeedsConstraint error + // [X] given the ext_mint_authority is not the required PDA + // [X] it reverts with a SeedsConstraint error + // [X] given the ext_mint does not have a freeze authority + // [X] it reverts with a InvalidMint error + // [X] given the wrap authorities are not unique + // [X] it reverts with an InvalidParam error + + // given the m_mint is not owned by the token2022 program + // it reverts with a ConstraintAddress error -> actually get AccountNotInitialized error before this + test("m_mint not owned by token2022 - reverts", async () => { + // Create a mint owned by a different program + const wrongMint = new Keypair(); + await $.createMint(wrongMint, $.nonAdmin.publicKey, false); + + // Create/get the m vault ATA for the wrong mint to avoid account not initialized error + const vaultMTokenAccount = await $.getATA( + wrongMint.publicKey, + $.getMVault(), + false + ); + + // Attempt to send the transaction + // We get an AccountNotInitialized error here because it's impossible to create + // a m earner account that matches the vaultATA for the wrong mint + await $.expectAnchorError( + (variant === Variant.NoYield + ? $.ext.methods.initialize([]) + : $.ext.methods.initialize([], new BN(0)) + ) + .accountsPartial({ + admin: $.nonAdmin.publicKey, + mMint: wrongMint.publicKey, + extMint: $.extMint.publicKey, + vaultMTokenAccount, + }) + .signers([$.nonAdmin]) + .rpc(), + "AccountNotInitialized" + ); + }); + + // given the ext_mint is not owned by the token2022 program + // it reverts with a ConstraintMintTokenProgram error + test("ext_mint not owned by token2022 - reverts", async () => { + // Create a mint owned by a different program + const wrongMint = new Keypair(); + await $.createMint(wrongMint, $.nonAdmin.publicKey, false); + + // Attempt to send the transaction + await $.expectAnchorError( + (variant === Variant.NoYield + ? $.ext.methods.initialize([]) + : $.ext.methods.initialize([], new BN(0)) + ) + .accounts({ + admin: $.nonAdmin.publicKey, + mMint: $.mMint.publicKey, + extMint: wrongMint.publicKey, + }) + .signers([$.nonAdmin]) + .rpc(), + "ConstraintMintTokenProgram" + ); + }); + + // given the decimals on ext_mint do not match M + // it reverts with a MintDecimals error + test("ext_mint incorrect decimals - reverts", async () => { + // Create a mint owned by a different program + const badMint = new Keypair(); + await $.createMint(badMint, $.nonAdmin.publicKey, true, 9); + + // Attempt to send the transaction + await $.expectAnchorError( + (variant === Variant.NoYield + ? $.ext.methods.initialize([]) + : $.ext.methods.initialize([], new BN(0)) + ) + .accounts({ + admin: $.nonAdmin.publicKey, + mMint: $.mMint.publicKey, + extMint: badMint.publicKey, + }) + .signers([$.nonAdmin]) + .rpc(), + "ConstraintMintDecimals" + ); + }); + + // given the M earn global account is invalid + // it reverts with a seeds constraint (or other account error) + test("m_earn_global_account is incorrect - reverts", async () => { + // Change the m earn global account + const mEarnGlobalAccount = PublicKey.unique(); + if (mEarnGlobalAccount == $.getEarnGlobalAccount()) return; + + // Attempt to send transaction + // Expect error (could be one of several "SeedsConstraint", "AccountOwnedByWrongProgram", "AccountNotInitialized") + await $.expectSystemError( + (variant === Variant.NoYield + ? $.ext.methods.initialize([]) + : $.ext.methods.initialize([], new BN(0)) + ) + .accountsPartial({ + admin: $.nonAdmin.publicKey, + mMint: $.mMint.publicKey, + extMint: $.extMint.publicKey, + mEarnGlobalAccount: mEarnGlobalAccount, + }) + .signers([$.nonAdmin]) + .rpc() + ); + }); + + // given the m_earner_account is not the required PDA + // it reverts with a seeds constraint (or other account error) + test("m_earner_account is incorrect - reverts", async () => { + // Change the m earner account + const mEarnerAccount = PublicKey.unique(); + if ( + mEarnerAccount.equals( + $.getMEarnerAccount( + await $.getATA($.mMint.publicKey, $.getMVault()) + ) + ) + ) + return; + + // Attempt to send transaction + // Expect error (could be one of several "SeedsConstraint", "AccountOwnedByWrongProgram", "AccountNotInitialized") + await $.expectSystemError( + (variant === Variant.NoYield + ? $.ext.methods.initialize([]) + : $.ext.methods.initialize([], new BN(0)) + ) + .accountsPartial({ + admin: $.nonAdmin.publicKey, + mMint: $.mMint.publicKey, + extMint: $.extMint.publicKey, + mEarnerAccount: mEarnerAccount, + }) + .signers([$.nonAdmin]) + .rpc() + ); + }); + + // given ext_mint_authority is not required PDA + // it reverts with a seeds constraint + test("ext_mint_authority is incorrect - reverts", async () => { + // Change the ext mint authority + const extMintAuthority = PublicKey.unique(); + if (extMintAuthority == $.getExtMintAuthority()) return; + + // Attempt to send transaction + // Expect error (could be one of several "SeedsConstraint", "AccountOwnedByWrongProgram", "AccountNotInitialized") + await $.expectSystemError( + (variant === Variant.NoYield + ? $.ext.methods.initialize([]) + : $.ext.methods.initialize([], new BN(0)) + ) + .accountsPartial({ + admin: $.nonAdmin.publicKey, + mMint: $.mMint.publicKey, + extMint: $.extMint.publicKey, + extMintAuthority: extMintAuthority, + }) + .signers([$.nonAdmin]) + .rpc() + ); + }); + + // given the ext_mint does not have a freeze authority + // it reverts with a InvalidMint error + test("ext_mint does not have a freeze authority - reverts", async () => { + // Create a mint without a freeze authority + const wrongMint = new Keypair(); + await $.createMint(wrongMint, $.nonAdmin.publicKey, true, 6, false); + + // Attempt to send the transaction + await $.expectAnchorError( + (variant === Variant.NoYield + ? $.ext.methods.initialize([]) + : $.ext.methods.initialize([], new BN(0)) + ) + .accounts({ + admin: $.nonAdmin.publicKey, + mMint: $.mMint.publicKey, + extMint: wrongMint.publicKey, + }) + .signers([$.nonAdmin]) + .rpc(), + "InvalidMint" + ); + }); + + // given wrap authorities includes a duplicate, non-default public key + // it reverts with an InvalidParam error + test("wrap authorities includes a duplicate public key - reverts", async () => { + // Change the wrap authorities + const wrapAuthorities: PublicKey[] = $.createUniqueKeyArray(10); + wrapAuthorities[0] = wrapAuthorities[1]; + + // Attempt to send transaction + await $.expectAnchorError( + (variant === Variant.NoYield + ? $.ext.methods.initialize(wrapAuthorities) + : $.ext.methods.initialize(wrapAuthorities, new BN(0)) + ) + .accounts({ + admin: $.nonAdmin.publicKey, + mMint: $.mMint.publicKey, + extMint: $.extMint.publicKey, + }) + .signers([$.nonAdmin]) + .rpc(), + "InvalidParam" + ); + }); + + // no yield test cases + // [X] given all accounts and params are correct + // [X] the global account is created + // [X] the admin is set to the signer + // [X] the m_mint is set correctly + // [X] the ext_mint is set correctly + // [X] the m_earn_global_account is set correctly + // [X] the bumps are set correctly + // [X] the wrap authorities are set correctly + + if (variant === Variant.NoYield) { + // given accounts and params are correct + // it creates the global account + // it sets the admin to the signer + // it sets the m_mint to the provided mint + // it sets the ext_mint to the provided mint + // it sets the m_earn_global_account to the provided account + // it sets the scaled ui amount multiplier and timestamp to the values on the m earner account + // it sets the bumps to the correct values + test("initialize - success", async () => { + // Get a random number of wrap authorities + // We use the padded array to check the stored state after the call + const numWrapAuthorities = randomInt(10); + const wrapAuthorities: PublicKey[] = + $.createUniqueKeyArray(numWrapAuthorities); + + // Derive PDA bumps + const [, bump] = PublicKey.findProgramAddressSync( + [Buffer.from("global")], + $.ext.programId + ); + const [, mVaultBump] = PublicKey.findProgramAddressSync( + [Buffer.from("m_vault")], + $.ext.programId + ); + const [, extMintAuthorityBump] = PublicKey.findProgramAddressSync( + [Buffer.from("mint_authority")], + $.ext.programId + ); + + // Ensure the global account has not been created yet + const globalAccount = $.getExtGlobalAccount(); + $.expectAccountEmpty(globalAccount); + + // Send the transaction + await $.ext.methods + .initialize(wrapAuthorities) + .accounts({ + admin: $.admin.publicKey, + mMint: $.mMint.publicKey, + extMint: $.extMint.publicKey, + }) + .signers([$.admin]) + .rpc(); + + // Check the state of the global account + await $.expectExtGlobalState({ + admin: $.admin.publicKey, + extMint: $.extMint.publicKey, + mMint: $.mMint.publicKey, + mEarnGlobalAccount: $.getEarnGlobalAccount(), + bump, + mVaultBump, + extMintAuthorityBump, + yieldConfig: {}, + wrapAuthorities, + }); + + // Confirm the size of the global account based on the number of wrap authorities + const expectedSize = 143 + wrapAuthorities.length * 32; // 143 bytes base size + 4 bytes for vector length + 32 bytes per wrap authority + const extGlobalSize = await $.provider.connection + .getAccountInfo(globalAccount) + .then((info) => info?.data.length || 0); + expect(extGlobalSize).toEqual(expectedSize); + }); + } + + // scaled ui test cases + // [X] given the ext_mint does not have the scaled ui amount extension + // [X] it reverts with a InvalidMint error + // [X] given the ext_mint has the scaled ui amount extension, but the authority is not the mint authority PDA + // [X] it reverts with an InvalidMint error + // [X] given all accounts and params are correct + // [X] the global account is created + // [X] the admin is set to the signer + // [X] the m_mint is set correctly + // [X] the ext_mint is set correctly + // [X] the m_earn_global_account is set correctly + // [X] the bumps are set correctly + // [X] the wrap authorities are set correctly + // [X] the multiplier on the ext mint is initialized to m index + // [X] the timestamp on the ext mint is set to the m timestamp + + if (variant === Variant.ScaledUiAmount) { + // given the ext_mint does not have the scaled ui amount extension + // it reverts with a InvalidMint error + test("ext_mint does not have the scaled ui amount extension - reverts", async () => { + // Create a mint without the scaled ui amount extension + const wrongMint = new Keypair(); + await $.createMint(wrongMint, $.getExtMintAuthority(), true, 6); // valid otherwise + + // Attempt to send the transaction + await $.expectAnchorError( + $.ext.methods + .initialize([], new BN(0)) + .accounts({ + admin: $.nonAdmin.publicKey, + mMint: $.mMint.publicKey, + extMint: wrongMint.publicKey, + }) + .signers([$.nonAdmin]) + .rpc(), + "InvalidMint" + ); + }); + + // given the ext_mint has the scaled ui amount extension, but the authority is not the mint authority PDA + // it reverts with an InvalidMint error + test("ext_mint has the scaled ui amount extension, but the authority is not the mint authority PDA - reverts", async () => { + // Create a mint with the scaled ui amount extension + const wrongMint = new Keypair(); + await $.createScaledUiMint(wrongMint, $.nonAdmin.publicKey, 6); + + // Attempt to send the transaction + await $.expectAnchorError( + $.ext.methods + .initialize([], new BN(0)) + .accounts({ + admin: $.nonAdmin.publicKey, + mMint: $.mMint.publicKey, + extMint: wrongMint.publicKey, + }) + .signers([$.nonAdmin]) + .rpc(), + "InvalidMint" + ); + }); + + // given accounts and params are correct + // it creates the global account + // it sets the admin to the signer + // it sets the m_mint to the provided mint + // it sets the ext_mint to the provided mint + // it sets the m_earn_global_account to the provided account + // it sets the scalued ui amount multiplier and timestamp to the values on the m earn global account + // it sets the bumps to the correct values + test("initialize - success", async () => { + // Get a random number of wrap authorities + // We use the padded array to check the stored state after the call + const numWrapAuthorities = randomInt(10); + const wrapAuthorities: PublicKey[] = + $.createUniqueKeyArray(numWrapAuthorities); + + // Derive PDA bumps + const [, bump] = PublicKey.findProgramAddressSync( + [Buffer.from("global")], + $.ext.programId + ); + const [, mVaultBump] = PublicKey.findProgramAddressSync( + [Buffer.from("m_vault")], + $.ext.programId + ); + const [, extMintAuthorityBump] = PublicKey.findProgramAddressSync( + [Buffer.from("mint_authority")], + $.ext.programId + ); + + // Ensure the global account has not been created yet + const globalAccount = $.getExtGlobalAccount(); + $.expectAccountEmpty(globalAccount); + + // Get a random fee bps + const feeBps = new BN(randomInt(10000)); + + // Send the transaction + await $.ext.methods + .initialize(wrapAuthorities, feeBps) + .accounts({ + admin: $.admin.publicKey, + mMint: $.mMint.publicKey, + extMint: $.extMint.publicKey, + }) + .signers([$.admin]) + .rpc(); + + // Check the state of the global account + await $.expectExtGlobalState({ + admin: $.admin.publicKey, + extMint: $.extMint.publicKey, + mMint: $.mMint.publicKey, + mEarnGlobalAccount: $.getEarnGlobalAccount(), + bump, + mVaultBump, + extMintAuthorityBump, + wrapAuthorities, + yieldConfig: { + feeBps, + lastMIndex: initialIndex, + lastExtIndex: new BN(1e12), + }, + }); + + // Check the size of the global account based on the number of wrap authorities + const expectedSize = 143 + 24 + wrapAuthorities.length * 32; // 143 bytes base size + 24 yield config size + 32 bytes per wrap authority + const extGlobalSize = await $.provider.connection + .getAccountInfo(globalAccount) + .then((info) => info?.data.length || 0); + expect(extGlobalSize).toEqual(expectedSize); + + // Check the state of the mint + await $.expectScaledUiAmountConfig($.extMint.publicKey, { + authority: $.getExtMintAuthority(), + multiplier: 1.0, + newMultiplierEffectiveTimestamp: BigInt( + $.currentTime().toString() + ), + newMultiplier: 1.0, + }); + }); + } + }); + + describe("set_m_mint unit tests", () => { + beforeEach(async () => { + const feeBps = + variant === Variant.NoYield ? undefined : new BN(randomInt(10000)); + // Initialize the extension program + await $.initializeExt( + [$.admin.publicKey, $.wrapAuthority.publicKey], + feeBps + ); + + // wrap some tokens to the make the m vault's balance non-zero + await $.wrap($.admin, initialSupply); + }); + + // 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 m mint is not owned by the token2022 program + // [X] it reverts with a ConstraintMintTokenProgram error + // [X] given the new m mint has a different number of decimals than the existing m mint + // [X] it reverts with a ConstraintMintDecimals error + // [X] given the m vault is not the m vault PDA + // [X] it reverts with a ConstraintSeeds error + // [X] given the m vault token account for the current m mint is not the m vault PDA's ATA + // [X] it reverts with a ConstraintAssociated error + // [X] given the m vault token account for the new m mint is not the m vault PDA's ATA + // [X] it reverts with a ConstraintAssociated error + // [X] given the m vault token account for the new m mint has fewer tokens than the m vault token account for the current m mint + // [X] it reverts with an InsufficientCollateral error + // [X] given all the accounts are correct + // [X] it sets the m mint to the new mint + + // given the admin does not sign the transaction + // it reverts with a NotAuthorized error + test("admin does not sign - reverts", async () => { + // Create a new m mint that is valid + const newMint = new Keypair(); + await $.createMint(newMint, $.nonAdmin.publicKey, true, 6); + + // Create the m vault ATA for the new mint + await $.createATA(newMint.publicKey, $.getMVault()); + + // Attempt to send the transaction + await $.expectAnchorError( + $.ext.methods + .setMMint() + .accountsPartial({ + admin: $.nonAdmin.publicKey, + newMMint: newMint.publicKey, + }) + .signers([$.nonAdmin]) + .rpc(), + "NotAuthorized" + ); + }); + + // given the admin signs the transaction + // given the new m mint is not owned by the token2022 program + // it reverts with a ConstraintAddress error + test("new m mint not owned by token2022 - reverts", async () => { + // Create a new m mint that is valid + const newMint = new Keypair(); + await $.createMint(newMint, $.nonAdmin.publicKey, false); + + // Create/get the m vault ATA for the new mint + const newVaultATA = await $.getATA( + newMint.publicKey, + $.getMVault(), + false + ); + + // Attempt to send the transaction + await $.expectAnchorError( + $.ext.methods + .setMMint() + .accountsPartial({ + admin: $.admin.publicKey, + newMMint: newMint.publicKey, + newVaultMTokenAccount: newVaultATA, + }) + .signers([$.admin]) + .rpc(), + "ConstraintMintTokenProgram" + ); + }); + + // given the new m mint has a different number of decimals than the existing m mint + // it reverts with a ConstraintMintDecimals error + test("new m mint incorrect decimals - reverts", async () => { + // Create a new m mint that is valid + const newMint = new Keypair(); + await $.createMint(newMint, $.nonAdmin.publicKey, true, 9); + + // Create the m vault ATA for the new mint + await $.createATA(newMint.publicKey, $.getMVault()); + + // Attempt to send the transaction + await $.expectAnchorError( + $.ext.methods + .setMMint() + .accountsPartial({ + admin: $.admin.publicKey, + newMMint: newMint.publicKey, + }) + .signers([$.admin]) + .rpc(), + "ConstraintMintDecimals" + ); + }); + + // given the m vault is not the m vault PDA + // it reverts with a SeedsConstraint error + test("m vault is not the m vault PDA - reverts", async () => { + // Create a new m mint that is valid + const newMint = new Keypair(); + await $.createMint(newMint, $.nonAdmin.publicKey, true, 6); + + // Create fake m vault PDA + const fakeMVault = PublicKey.unique(); + + // Create/get the m vault ATA for the old mint + const oldVaultATA = await $.getATA($.mMint.publicKey, fakeMVault); + + // Create/get the m vault ATA for the new mint + const newVaultATA = await $.getATA(newMint.publicKey, fakeMVault); + + // Attempt to send the transaction + await $.expectAnchorError( + $.ext.methods + .setMMint() + .accountsPartial({ + admin: $.admin.publicKey, + newMMint: newMint.publicKey, + mVault: fakeMVault, + vaultMTokenAccount: oldVaultATA, + newVaultMTokenAccount: newVaultATA, + }) + .signers([$.admin]) + .rpc(), + "ConstraintSeeds" + ); + }); + + // given the m vault token account for the current m mint is not the m vault PDA's ATA + // it reverts with a ConstraintAssociated error + test("m vault token account for current m mint is not the m vault PDA's ATA - reverts", async () => { + // Create a new m mint that is valid + const newMint = new Keypair(); + await $.createMint(newMint, $.nonAdmin.publicKey, true, 6); + + // Create the m vault ATA for the new mint + await $.createATA(newMint.publicKey, $.getMVault()); + + // Change the m vault token account + const { tokenAccount: nonAtaAccount } = await $.createTokenAccount( + $.mMint.publicKey, + $.getMVault(), + true, + true + ); + + // Attempt to send the transaction + await $.expectAnchorError( + $.ext.methods + .setMMint() + .accountsPartial({ + admin: $.admin.publicKey, + newMMint: newMint.publicKey, + vaultMTokenAccount: nonAtaAccount, + }) + .signers([$.admin]) + .rpc(), + "ConstraintAssociated" + ); + }); + + // given the m vault token account for the new m mint is not the m vault PDA's ATA + // it reverts with a ConstraintAssociated error + test("m vault token account for new m mint is not the m vault PDA's ATA - reverts", async () => { + // Create a new m mint that is valid + const newMint = new Keypair(); + await $.createMint(newMint, $.nonAdmin.publicKey, true, 6); + + // Change the m vault token account + const { tokenAccount: nonAtaAccount } = await $.createTokenAccount( + newMint.publicKey, + $.getMVault(), + true, + true + ); + + // Attempt to send the transaction + await $.expectAnchorError( + $.ext.methods + .setMMint() + .accountsPartial({ + admin: $.admin.publicKey, + newMMint: newMint.publicKey, + newVaultMTokenAccount: nonAtaAccount, + }) + .signers([$.admin]) + .rpc(), + "ConstraintAssociated" + ); + }); + + // given the m vault token account for the new m mint has fewer tokens than the m vault token account for the current m mint + // it reverts with an InsufficientCollateral error + test("new m mint vault token account has fewer tokens than current m mint vault token account - reverts", async () => { + // Create a new m mint that is valid + const newMint = new Keypair(); + await $.createMint(newMint, $.nonAdmin.publicKey, true, 6); + + // Create/get the ATA for the m vault for the new mint and mint some tokens to it + const mVaultATA: PublicKey = await $.getATA( + newMint.publicKey, + $.getMVault() + ); + + const amount = BigInt(randomInt(initialSupply.toNumber())); + + const mintToInstruction = createMintToCheckedInstruction( + newMint.publicKey, + mVaultATA, + $.nonAdmin.publicKey, + amount, + 6, + [], + TOKEN_2022_PROGRAM_ID + ); + + let tx = new Transaction(); + tx.add(mintToInstruction); + await $.provider.sendAndConfirm!(tx, [$.nonAdmin]); + + // Send the transaction + // Expect an insufficient collateral error + await $.expectAnchorError( + $.ext.methods + .setMMint() + .accountsPartial({ + admin: $.admin.publicKey, + newMMint: newMint.publicKey, + }) + .signers([$.admin]) + .rpc(), + "InsufficientCollateral" + ); + }); + + // given all the accounts are correct + // it sets the m mint to the new mint + // Create a new m mint that is valid + test("set m mint - success", async () => { + const newMint = new Keypair(); + await $.createMint(newMint, $.nonAdmin.publicKey, true, 6); + + // Create/get the ATA for the m vault for the new mint and mint some tokens to it + const mVaultATA: PublicKey = await $.getATA( + newMint.publicKey, + $.getMVault() + ); + + const amount = BigInt(initialSupply.toString()); + + const mintToInstruction = createMintToCheckedInstruction( + newMint.publicKey, + mVaultATA, + $.nonAdmin.publicKey, + amount, + 6, + [], + TOKEN_2022_PROGRAM_ID + ); + + let tx = new Transaction(); + tx.add(mintToInstruction); + await $.provider.sendAndConfirm!(tx, [$.nonAdmin]); + + // Send the transaction + await $.ext.methods + .setMMint() + .accountsPartial({ + admin: $.admin.publicKey, + newMMint: newMint.publicKey, + }) + .signers([$.admin]) + .rpc(); + + // Check that the m mint was updated + $.expectExtGlobalState({ + mMint: newMint.publicKey, + }); + }); + }); + + describe("add_wrap_authority unit tests", () => { + let wrapAuthorities: PublicKey[]; + + beforeEach(async () => { + wrapAuthorities = [$.admin.publicKey, $.wrapAuthority.publicKey]; + + const feeBps = + variant === Variant.NoYield ? new BN(0) : new BN(randomInt(10000)); + // Initialize the extension program + await $.initializeExt(wrapAuthorities, feeBps); + }); + + // 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 wrap authority is already in the list + // [X] it reverts with a InvalidParam error + // [X] given the new wrap authority is not in the list + // [X] it adds the new wrap authority to the list + // [X] it resizes the ext global account to accommodate the new wrap authority + + // given the admin does not sign the transaction + // it reverts with a NotAuthorized error + test("admin does not sign - reverts", async () => { + // Attempt to send the transaction + await $.expectAnchorError( + $.ext.methods + .addWrapAuthority($.nonWrapAuthority.publicKey) + .accounts({ + admin: $.nonAdmin.publicKey, + }) + .signers([$.nonAdmin]) + .rpc(), + "NotAuthorized" + ); + }); + + // given the admin signs the transaction + // given the new wrap authority is already in the list + // it reverts with a InvalidParam error + test("new wrap authority already in the list - reverts", async () => { + // Attempt to send the transaction + await $.expectAnchorError( + $.ext.methods + .addWrapAuthority($.wrapAuthority.publicKey) + .accounts({ admin: $.admin.publicKey }) + .signers([$.admin]) + .rpc(), + "InvalidParam" + ); + }); + + // given the admin signs the transaction + // given the new wrap authority is not in the list + // it adds the new wrap authority to the list + // it resizes the ext global account to accommodate the new wrap authority + test("new wrap 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 + .addWrapAuthority($.nonWrapAuthority.publicKey) + .accounts({ + admin: $.admin.publicKey, + }) + .signers([$.admin]) + .rpc(); + + // Check that the wrap authority was added + wrapAuthorities.push($.nonWrapAuthority.publicKey); + + await $.expectExtGlobalState({ + wrapAuthorities, + }); + + // 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 wrap authority + }); + }); + + describe("remove_wrap_authority unit tests", () => { + let wrapAuthorities: PublicKey[]; + + beforeEach(async () => { + wrapAuthorities = [$.admin.publicKey, $.wrapAuthority.publicKey]; + + const feeBps = + variant === Variant.NoYield ? new BN(0) : new BN(randomInt(10000)); + // Initialize the extension program + await $.initializeExt(wrapAuthorities, feeBps); + }); + + // 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 wrap authority is not in the list + // [X] it reverts with a InvalidParam error + // [X] given the wrap authority is in the list + // [X] it removes the wrap authority from the list + // [X] it resizes the ext global account down to accommodate the removed wrap authority + + // given the admin does not sign the transaction + // it reverts with a NotAuthorized error + test("admin does not sign - reverts", async () => { + // Attempt to send the transaction + await $.expectAnchorError( + $.ext.methods + .removeWrapAuthority($.wrapAuthority.publicKey) + .accounts({ + admin: $.nonAdmin.publicKey, + }) + .signers([$.nonAdmin]) + .rpc(), + "NotAuthorized" + ); + }); + + // given the admin signs the transaction + // given the wrap authority is not in the list + // it reverts with a InvalidParam error + test("wrap authority not in the list - reverts", async () => { + // Attempt to send the transaction + await $.expectAnchorError( + $.ext.methods + .removeWrapAuthority($.nonWrapAuthority.publicKey) + .accounts({ admin: $.admin.publicKey }) + .signers([$.admin]) + .rpc(), + "InvalidParam" + ); + }); + + // given the admin signs the transaction + // given the wrap authority is in the list + // it removes the wrap authority from the list + // it resizes the ext global account down to accommodate the removed wrap authority + test("wrap 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 + .removeWrapAuthority($.wrapAuthority.publicKey) + .accounts({ + admin: $.admin.publicKey, + }) + .signers([$.admin]) + .rpc(); + + // Check that the wrap authority was added + wrapAuthorities.pop(); + + await $.expectExtGlobalState({ + wrapAuthorities, + }); + + // 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("claim_fees unit tests", () => { + // general 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 m vault is not the m vault PDA + // [X] it reverts with a ConstraintSeeds 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] given the m earner account does not match the derived PDA + // [X] it reverts with a ConstraintSeeds error + // [X] given the recipient token account is not a token account for the m mint + // [X] it reverts with a ConstraintTokenMint error + + const initialWrappedAmount = new BN(10_000_000); // 10 with 6 decimals + let wrapAuthorities: PublicKey[]; + const feeBps = new BN(randomInt(1, 10000)); // non-zero + const startIndex = new BN(randomInt(initialIndex.toNumber() + 1, 2e12)); + + beforeEach(async () => { + wrapAuthorities = [$.admin.publicKey, $.wrapAuthority.publicKey]; + // Initialize the extension program + await $.initializeExt(wrapAuthorities, feeBps); + + // Wrap some tokens from the admin to make the m vault's balance non-zero + await $.wrap($.admin, initialWrappedAmount); + + // Propagate the start index + await $.propagateIndex(startIndex); + + // Claim yield for the m vault and complete the claim cycle + const mVault = $.getMVault(); + const mVaultATA = await $.getATA($.mMint.publicKey, mVault); + await $.mClaimFor(mVault, await $.getTokenBalance(mVaultATA)); + await $.mCompleteClaims(); + + if (variant !== Variant.NoYield) { + // Sync the multiplier + await $.sync(); + } + // Reset the blockhash to avoid issues with duplicate transactions + $.svm.expireBlockhash(); + }); + + // given the admin does not sign the transaction + // it reverts with a NotAuthorized error + test("admin does not sign - reverts", async () => { + const recipientExtTokenAccount = await $.getATA( + $.extMint.publicKey, + $.nonAdmin.publicKey + ); + + // Attempt to send the transaction + await $.expectAnchorError( + $.ext.methods + .claimFees() + .accountsPartial({ + admin: $.nonAdmin.publicKey, + recipientExtTokenAccount, + }) + .signers([$.nonAdmin]) + .rpc(), + "NotAuthorized" + ); + }); + + // given the m vault is not the m vault PDA + // it reverts with a ConstraintSeeds error + test("m vault is not the m vault PDA - reverts", async () => { + // Change the m vault + const mVault = PublicKey.unique(); + if (mVault === $.getMVault()) return; + + // Create the ATA for the fake m vault so we avoid account not initialized errors + const mVaultATA = await $.getATA($.mMint.publicKey, mVault); + + // Create earner account for the fake m vault + const mEarnerAccount = await $.addMEarner(mVault); + + const recipientExtTokenAccount = await $.getATA( + $.extMint.publicKey, + $.admin.publicKey + ); + + // Attempt to send the transaction + await $.expectAnchorError( + $.ext.methods + .claimFees() + .accountsPartial({ + admin: $.admin.publicKey, + mVault, + vaultMTokenAccount: mVaultATA, + mEarnerAccount, + recipientExtTokenAccount, + }) + .signers([$.admin]) + .rpc(), + "ConstraintSeeds" + ); + }); + + // given the m vault token account is not the m vault PDA's ATA + // it reverts with a ConstraintAssociated error + test("m vault token account is not the m vault PDA's ATA - reverts", async () => { + // Create a token account for the M vault that is not the ATA + const mVault = $.getMVault(); + const { tokenAccount: nonAtaAccount } = await $.createTokenAccount( + $.mMint.publicKey, + mVault, + true, + true + ); + + // Remove the m vault's current earner account and add the one for the non-ATA + await $.removeMEarner(mVault); + const mEarnerAccount = await $.addMEarner(mVault, nonAtaAccount); + + const recipientExtTokenAccount = await $.getATA( + $.extMint.publicKey, + $.admin.publicKey + ); + + // Attempt to send the transaction + await $.expectAnchorError( + $.ext.methods + .claimFees() + .accountsPartial({ + admin: $.admin.publicKey, + vaultMTokenAccount: nonAtaAccount, + mEarnerAccount, + recipientExtTokenAccount, + }) + .signers([$.admin]) + .rpc(), + "ConstraintAssociated" + ); + }); + + // given the ext mint does not match the one on the global account + // it reverts with an InvalidMint error + test("ext mint does not match global account - reverts", async () => { + // Create a new mint + const wrongMint = new Keypair(); + await $.createMint(wrongMint, $.nonAdmin.publicKey, true, 6); + + const recipientExtTokenAccount = await $.getATA( + wrongMint.publicKey, + $.admin.publicKey + ); + + // Attempt to send the transaction + await $.expectAnchorError( + $.ext.methods + .claimFees() + .accountsPartial({ + admin: $.admin.publicKey, + extMint: wrongMint.publicKey, + recipientExtTokenAccount, + }) + .signers([$.admin]) + .rpc(), + "InvalidMint" + ); + }); + + // given the ext mint authority is not the ext mint authority PDA + // it reverts with a ConstraintSeeds error + test("ext mint authority is not the ext mint authority PDA - reverts", async () => { + // Change the ext mint authority + const extMintAuthority = PublicKey.unique(); + if (extMintAuthority === $.getExtMintAuthority()) return; + + const recipientExtTokenAccount = await $.getATA( + $.extMint.publicKey, + $.admin.publicKey + ); + // Attempt to send the transaction + await $.expectAnchorError( + $.ext.methods + .claimFees() + .accountsPartial({ + admin: $.admin.publicKey, + extMintAuthority, + recipientExtTokenAccount, + }) + .signers([$.admin]) + .rpc(), + "ConstraintSeeds" + ); + }); + + // given the m earner account does not match the derived one + // it reverts with a ConstraintSeeds / AccountNotInitialized error + test("m earner account does not match derived pubkey - reverts", async () => { + // Change the m earner account + const mEarnerAccount = PublicKey.unique(); + if ( + mEarnerAccount.equals( + $.getMEarnerAccount( + await $.getATA($.mMint.publicKey, $.getMVault()) + ) + ) + ) + return; + + const recipientExtTokenAccount = await $.getATA( + $.extMint.publicKey, + $.admin.publicKey + ); + + // Attempt to send the transaction + await $.expectSystemError( + $.ext.methods + .claimFees() + .accountsPartial({ + admin: $.admin.publicKey, + mEarnerAccount, + recipientExtTokenAccount, + }) + .signers([$.admin]) + .rpc() + ); + }); + + // given the recipient token account is not a token account for the ext mint + // it reverts with a ConstraintTokenMint error + test("recipient token account is not for ext mint - reverts", async () => { + // Create a token account for the m mint + const wrongTokenAccount = await $.getATA( + $.mMint.publicKey, + $.admin.publicKey + ); + + // Attempt to send the transaction + await $.expectAnchorError( + $.ext.methods + .claimFees() + .accountsPartial({ + admin: $.admin.publicKey, + recipientExtTokenAccount: wrongTokenAccount, + }) + .signers([$.admin]) + .rpc(), + "ConstraintTokenMint" + ); + }); + + // yield variant test cases + // [X] given all the accounts are correct + // [X] given the multiplier is not synced + // [X] it syncs the multiplier to the current + // [X] given the m vault has excess collateral + // [X] it transfers the excess collateral to the recipient token account + // [X] given the m vault does not have excess collateral + // [X] it reverts with an InsufficientCollateral error + // [X] given the multiplier is already synced + // [X] given the m vault has excess collateral + // [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.ScaledUiAmount) { + // given all accounts are correct + // given the multiplier is not synced + // it syncs the multiplier to the current + // given the m vault has excess collateral + // it transfers the excess collateral to the recipient token account + test("multiplier not synced, excess collateral exists - success", async () => { + // warp forward in time slightly + $.warp(new BN(60), true); + + // Propagate a new index to create a situation where multiplier needs sync + const newIndex = new BN(randomInt(startIndex.toNumber() + 1, 2e12)); + await $.propagateIndex(newIndex); + + // Claim yield to ensure vault has enough collateral + const mVault = $.getMVault(); + const mVaultATA = await $.getATA($.mMint.publicKey, mVault); + await $.mClaimFor(mVault, await $.getTokenBalance(mVaultATA)); + await $.mCompleteClaims(); + + // Cache balances before claim excess + const initialVaultBalance = await $.getTokenBalance(mVaultATA); + const recipientATA = await $.getATA( + $.extMint.publicKey, + $.admin.publicKey + ); + + // Get the new multiplier calculate the expected excess + const multiplier = await $.getNewMultiplier(newIndex); + + const initialRecipientPrincipal = await $.getTokenBalance( + recipientATA + ); + const initialRecipientBalance = await $.getTokenUiBalance( + recipientATA, + multiplier + ); + + const extSupply = await getMint( + $.provider.connection, + $.extMint.publicKey, + undefined, + TOKEN_2022_PROGRAM_ID + ).then((mint) => mint.supply); + + const requiredCollateral = new BN( + Math.ceil(Number(extSupply) * multiplier) + ); + + const expectedExcess = initialVaultBalance.sub(requiredCollateral); + const expectedExcessPrincipal = new BN( + Math.floor(Number(expectedExcess) / multiplier) + ); + + // Setup and execute the instruction + await $.ext.methods + .claimFees() + .accountsPartial({ + admin: $.admin.publicKey, + recipientExtTokenAccount: recipientATA, + }) + .signers([$.admin]) + .rpc(); + + // Verify multiplier was updated + + $.expectScaledUiAmountConfig($.extMint.publicKey, { + authority: $.getExtMintAuthority(), + multiplier, + newMultiplier: multiplier, + newMultiplierEffectiveTimestamp: BigInt( + $.currentTime().toString() + ), + }); + + // Verify excess tokens were transferred + + $.expectTokenBalance(mVaultATA, initialVaultBalance); + $.expectTokenUiBalance( + recipientATA, + initialRecipientBalance.add(expectedExcess), + Comparison.LessThanOrEqual, + new BN(1) + ); + $.expectTokenBalance( + recipientATA, + initialRecipientPrincipal.add(expectedExcessPrincipal) + ); + }); + + // given all accounts are correct + // given the multiplier is already synced + // given the m vault has excess collateral + // it transfers the excess collateral to the recipient token account + test("multiplier already synced, excess collateral exists - success", async () => { + // Cache balances before claim excess + const mVaultATA = await $.getATA($.mMint.publicKey, $.getMVault()); + const initialVaultBalance = await $.getTokenBalance(mVaultATA); + const recipientATA = await $.getATA( + $.extMint.publicKey, + $.admin.publicKey + ); + + // Get the current multiplier and calculate the $.expected excess + const multiplier = await $.getCurrentMultiplier(); + const initialRecipientBalance = await $.getTokenUiBalance( + recipientATA, + multiplier + ); + const initialRecipientPrincipal = await $.getTokenBalance( + recipientATA + ); + + const extSupply = await getMint( + $.provider.connection, + $.extMint.publicKey, + undefined, + TOKEN_2022_PROGRAM_ID + ).then((mint) => mint.supply); + + const requiredCollateral = new BN( + Math.ceil(Number(extSupply) * multiplier) + ); + + const expectedExcess = initialVaultBalance.sub(requiredCollateral); + const expectedExcessPrincipal = new BN( + Math.floor(Number(expectedExcess) / multiplier) + ); + + await $.ext.methods + .claimFees() + .accountsPartial({ + admin: $.admin.publicKey, + recipientExtTokenAccount: recipientATA, + }) + .signers([$.admin]) + .rpc(); + + // Verify excess tokens were transferred + $.expectTokenBalance(mVaultATA, initialVaultBalance); + $.expectTokenUiBalance( + recipientATA, + initialRecipientBalance.add(expectedExcess), + Comparison.LessThanOrEqual, + new BN(1) + ); + $.expectTokenBalance( + recipientATA, + initialRecipientPrincipal.add(expectedExcessPrincipal) + ); + }); + + // given all accounts are correct + // given the multiplier is already synced + // given the m vault does not have excess collateral + // it completes successfully and does not transfer any tokens + test("multiplier already synced, no excess collateral - success", async () => { + // claim the existing excess so there isn't extra + await $.claimFees(); + $.svm.expireBlockhash(); + + // Cache balances before claim excess + const mVaultATA = await $.getATA($.mMint.publicKey, $.getMVault()); + const initialVaultBalance = await $.getTokenBalance(mVaultATA); + const recipientATA = await $.getATA( + $.extMint.publicKey, + $.admin.publicKey + ); + const initialRecipientBalance = await $.getTokenBalance( + recipientATA + ); + + // Attempt to send the transaction + await $.ext.methods + .claimFees() + .accountsPartial({ + admin: $.admin.publicKey, + recipientExtTokenAccount: recipientATA, + }) + .signers([$.admin]) + .rpc(); + + // Verify no tokens were transferred + $.expectTokenBalance(mVaultATA, initialVaultBalance); + $.expectTokenBalance(recipientATA, initialRecipientBalance); + }); + } + + // no yield test cases + // [X] given all the accounts are correct + // [X] given the m vault has excess collateral + // [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) { + // given all accounts are correct + // given the m vault has excess collateral + // it transfers the excess collateral to the recipient token account + test("excess collateral exists - success", async () => { + // Cache balances before claim excess + const mVaultATA = await $.getATA($.mMint.publicKey, $.getMVault()); + const initialVaultBalance = await $.getTokenBalance(mVaultATA); + const recipientATA = await $.getATA( + $.extMint.publicKey, + $.admin.publicKey + ); + const initialRecipientBalance = await $.getTokenBalance( + recipientATA + ); + + const extSupply = await getMint( + $.provider.connection, + $.extMint.publicKey, + undefined, + TOKEN_2022_PROGRAM_ID + ).then((mint) => mint.supply); + + const expectedExcess = initialVaultBalance.sub( + new BN(extSupply.toString()) + ); + + await $.ext.methods + .claimFees() + .accountsPartial({ + admin: $.admin.publicKey, + recipientExtTokenAccount: recipientATA, + }) + .signers([$.admin]) + .rpc(); + + // Verify excess tokens were transferred + $.expectTokenBalance(mVaultATA, initialVaultBalance); + $.expectTokenBalance( + recipientATA, + initialRecipientBalance.add(expectedExcess) + ); + }); + + // given all accounts are correct + // given the m vault does not have excess collateral + // it completes successfully and does not transfer any tokens + test("no excess collateral - success", async () => { + // claim the existing excess so there isn't extra + await $.claimFees(); + $.svm.expireBlockhash(); + + // Cache balances before claim excess + const mVaultATA = await $.getATA($.mMint.publicKey, $.getMVault()); + const initialVaultBalance = await $.getTokenBalance(mVaultATA); + const recipientATA = await $.getATA( + $.extMint.publicKey, + $.admin.publicKey + ); + const initialRecipientBalance = await $.getTokenBalance( + recipientATA + ); + + // Attempt to send the transaction + await $.ext.methods + .claimFees() + .accountsPartial({ + admin: $.admin.publicKey, + recipientExtTokenAccount: recipientATA, + }) + .signers([$.admin]) + .rpc(); + + // Verify no tokens were transferred + $.expectTokenBalance(mVaultATA, initialVaultBalance); + $.expectTokenBalance(recipientATA, initialRecipientBalance); + }); + } + }); + }); + + describe("wrap_authority instruction tests", () => { + const mintAmount = new BN(100_000_000); // 100 with 6 decimals + const initialWrappedAmount = new BN(100_000_000); // 100 with 6 decimals + + let wrapAuthorities: PublicKey[]; + const feeBps = new BN(randomInt(10000)); + + const startIndex = new BN(randomInt(initialIndex.toNumber() + 1, 2e12)); + + let vaultMTokenAccount: PublicKey; + + // Setup accounts with M tokens so we can test wrapping and unwrapping + beforeEach(async () => { + wrapAuthorities = [$.admin.publicKey, $.wrapAuthority.publicKey]; + vaultMTokenAccount = await $.getATA($.mMint.publicKey, $.getMVault()); + + // Initialize the extension program + await $.initializeExt(wrapAuthorities, feeBps); + + // Mint M tokens to a wrap authority and a non-wrap authority + await $.mintM($.wrapAuthority.publicKey, mintAmount); + await $.mintM($.nonWrapAuthority.publicKey, mintAmount); + + // Wrap some tokens from the admin to the make the m vault's balance non-zero + await $.wrap($.admin, initialWrappedAmount); + + // Propagate the start index + await $.propagateIndex(startIndex); + + // Claim yield for the m vault and complete the claim cycle + // so that the m vault is collateralized to start + await $.mClaimFor( + $.getMVault(), + await $.getTokenBalance(vaultMTokenAccount) + ); + await $.mCompleteClaims(); + + // Sync the scaled ui multiplier with the m index + if (variant !== Variant.NoYield) { + await $.sync(); + } + + // Claim excess tokens to make it easier to test collateral checks + try { + await $.claimFees(); + } catch (e) { + // Ignore the error if there are no excess tokens + } + }); + + describe("wrap unit tests", () => { + let fromMTokenAccount: PublicKey; + let toExtTokenAccount: PublicKey; + + beforeEach(async () => { + fromMTokenAccount = await $.getATA( + $.mMint.publicKey, + $.wrapAuthority.publicKey + ); + toExtTokenAccount = await $.getATA( + $.extMint.publicKey, + $.wrapAuthority.publicKey + ); + }); + + describe("index same as start", () => { + // test cases + // [X] given the m mint account does not match the one stored in the global account + // [X] it reverts with an InvalidAccount error + // [X] given the ext mint account does not match the one stored in the global account + // [X] it reverts with an InvalidAccount error + // [X] given the signer is not the authority on the from m token account and is not delegated by the owner + // [X] it reverts with a ConstraintTokenOwner error + // [X] given the vault M token account is not the M Vaults ATA for the M token mint + // [X] it reverts with a ConstraintAssociated error + // [X] given the from m token account is for the wrong mint + // [X] it reverts with a ConstraintTokenMint error + // [X] given the to ext token account is for the wrong mint + // [X] it reverts with a ConstraintTokenMint error + // [X] given a wrap authority is not provided + // [X] given the token authority is not in the wrap authorities list + // [X] it reverts with a NotAuthorized error + // [X] given the token authority is on the wrap authorities list + // [X] given the user does not have enough M tokens + // [X] it reverts with a ? error + // [X] given the user has enough M tokens + // [X] given the token authority is not the owner of the from M token account, but is delegated + // [X] it transfers the amount of M tokens from the user's M token account to the M vault token account + // [X] given the token authority is the owner of the from M token account + // [X] it transfers the amount of M tokens from the user's M token account to the M vault token account + // [X] it mints the amount of ext tokens to the user's ext token account + // [X] given the user wraps and then unwraps (roundtrip) + // [X] the starting balance and ending balance of the user's M token account are the same (within rounding error) + // [X] given a wrap authority is provided + // [X] given the wrap authority is not in the wrap authorities list + // [X] it reverts with a NotAuthorized error + // [X] given the wrap authority is in the wrap authorities list + // [X] given the user does not have enough M tokens + // [X] it reverts with a ? error + // [X] given the user has enough M tokens + // [X] given the token authority is not the owner of the from M token account, but is delegated + // [X] it transfers the amount of M tokens from the user's M token account to the M vault token account + // [X] given the token authority is the owner of the from M token account + // [X] it transfers the amount of M tokens from the user's M token account to the M vault token account + // [X] it mints the amount of ext tokens to the user's ext token account + // [X] given the user wraps and then unwraps (roundtrip) + // [X] the starting balance and ending balance of the user's M token account are the same (within rounding error) + + // given the m mint account does not match the one stored in the global account + // it reverts with an InvalidAccount error + test("M mint account does not match global account - reverts", async () => { + const wrongMint = Keypair.generate(); + await $.createMint(wrongMint, $.wrapAuthority.publicKey, true, 6); + + fromMTokenAccount = await $.getATA( + wrongMint.publicKey, + $.wrapAuthority.publicKey + ); + vaultMTokenAccount = await $.getATA( + wrongMint.publicKey, + $.getMVault() + ); + + // Attempt to send the transaction + // Expect an invalid account error -> becomes an AccountNotInitialized error because you can't create an earner account for the wrong mint + await $.expectAnchorError( + $.ext.methods + .wrap(mintAmount) + .accountsPartial({ + tokenAuthority: $.wrapAuthority.publicKey, + wrapAuthority: $.ext.programId, + mMint: wrongMint.publicKey, + fromMTokenAccount, + toExtTokenAccount, + vaultMTokenAccount, + }) + .signers([$.wrapAuthority]) + .rpc(), + "AccountNotInitialized" + ); + }); + + // given the ext mint account does not match the one stored in the global account + // it reverts with an InvalidAccount error + test("Ext mint account does not match global account - reverts", async () => { + const wrongMint = Keypair.generate(); + await $.createMint(wrongMint, $.wrapAuthority.publicKey, true, 6); + + toExtTokenAccount = await $.getATA( + wrongMint.publicKey, + $.wrapAuthority.publicKey + ); + + // Attempt to send the transaction + // Expect an invalid account error + await $.expectAnchorError( + $.ext.methods + .wrap(mintAmount) + .accountsPartial({ + tokenAuthority: $.wrapAuthority.publicKey, + wrapAuthority: $.ext.programId, + extMint: wrongMint.publicKey, + fromMTokenAccount, + toExtTokenAccount, + }) + .signers([$.wrapAuthority]) + .rpc(), + "InvalidAccount" + ); + }); + + // given the signer is not the authority on the user M token account and is not delegated + // it reverts with a ConstraintTokenOwner error + test("Token authority is not the authority on the from M token account and is not delegated - reverts", async () => { + // Get the ATA for another user + fromMTokenAccount = await $.getATA( + $.mMint.publicKey, + $.nonWrapAuthority.publicKey + ); + + // Attempt to send the transaction + // Expect revert with TokenOwner error + await $.expectSystemError( + $.ext.methods + .wrap(mintAmount) + .accounts({ + tokenAuthority: $.wrapAuthority.publicKey, + wrapAuthority: $.ext.programId, + fromMTokenAccount, + toExtTokenAccount, + }) + .signers([$.wrapAuthority]) + .rpc() + ); + }); + + // given the M vault token account is not the M vault PDA's ATA + // it reverts with a ConstraintAssociated error + test("M Vault Token account is the the M Vault PDA's ATA (other token account) - reverts", async () => { + // Create a token account for the M vault that is not the ATA + const mVault = $.getMVault(); + const { tokenAccount: vaultMTokenAccount } = + await $.createTokenAccount($.mMint.publicKey, mVault, true, true); + + // Remove the m vault's current earner account and add the one for the non-ATA + await $.removeMEarner(mVault); + const mEarnerAccount = await $.addMEarner( + mVault, + vaultMTokenAccount + ); + + // Attempt to send the transaction + // Expect revert with a ConstraintAssociated error + await $.expectAnchorError( + $.ext.methods + .wrap(mintAmount) + .accountsPartial({ + tokenAuthority: $.wrapAuthority.publicKey, + wrapAuthority: $.ext.programId, + fromMTokenAccount, + toExtTokenAccount, + vaultMTokenAccount, + mEarnerAccount, + }) + .signers([$.wrapAuthority]) + .rpc(), + "ConstraintAssociated" + ); + }); + + // given the from m token account is for the wrong mint + // it reverts with a ConstraintTokenMint error + test("From M token account is for wrong mint - reverts", async () => { + // Attempt to send the transaction + // Expect revert with a ConstraintTokenMint error + await $.expectAnchorError( + $.ext.methods + .wrap(mintAmount) + .accounts({ + tokenAuthority: $.wrapAuthority.publicKey, + wrapAuthority: $.ext.programId, + fromMTokenAccount: toExtTokenAccount, + toExtTokenAccount, + }) + .signers([$.wrapAuthority]) + .rpc(), + "ConstraintTokenMint" + ); + }); + + // given the to ext token account is for the wrong mint + // it reverts with a ConstraintTokenMint error + test("To Ext token account is for the wrong mint - reverts", async () => { + // Attempt to send the transaction + // Expect revert with a ConstraintTokenMint error + await $.expectAnchorError( + $.ext.methods + .wrap(mintAmount) + .accounts({ + tokenAuthority: $.wrapAuthority.publicKey, + wrapAuthority: $.ext.programId, + toExtTokenAccount: fromMTokenAccount, + fromMTokenAccount, + }) + .signers([$.wrapAuthority]) + .rpc(), + "ConstraintTokenMint" + ); + }); + + // given a wrap authority is not provided + // given the token authority is not in the wrap authorities list + // it reverts with a NotAuthorized error + test("Token authority is not in the wrap authorities list - reverts", async () => { + fromMTokenAccount = await $.getATA( + $.mMint.publicKey, + $.nonWrapAuthority.publicKey + ); + toExtTokenAccount = await $.getATA( + $.extMint.publicKey, + $.nonWrapAuthority.publicKey + ); + + // Attempt to send the transaction + // Expect revert with a NotAuthorized error + await $.expectAnchorError( + $.ext.methods + .wrap(mintAmount) + .accounts({ + tokenAuthority: $.nonWrapAuthority.publicKey, + wrapAuthority: $.ext.programId, + fromMTokenAccount, + toExtTokenAccount, + }) + .signers([$.nonWrapAuthority]) + .rpc(), + "NotAuthorized" + ); + }); + + // given a wrap authority is not provided + // given the token authority is on the wrap authorities list + // given the user does not have enough M tokens + // it reverts + test("Not enough M - reverts", async () => { + const wrapAmount = new BN( + randomInt(mintAmount.toNumber() + 1, 2 ** 48 - 1) + ); + + // Attempt to send the transaction + // Expect an error + await $.expectSystemError( + $.ext.methods + .wrap(wrapAmount) + .accounts({ + tokenAuthority: $.wrapAuthority.publicKey, + wrapAuthority: $.ext.programId, + fromMTokenAccount, + toExtTokenAccount, + }) + .signers([$.wrapAuthority]) + .rpc() + ); + }); + + // given a wrap authority is not provided + // given the token authority is on the wrap authorities list + // given the from token account has enough M tokens + // given the token authority is not the owner of the from M token account, but is delegated + // it transfers the amount of M tokens from the user's M token account to the M vault token account + // it mints the amount of ext tokens to the to ext token account + test("Wrap with delegated authority - success", async () => { + const wrapAmount = new BN(randomInt(1, mintAmount.toNumber() + 1)); + + // Approve (delegate) the wrap authority to spend the non-wrap authority's M tokens + const { sourceATA: fromMTokenAccount } = await $.approve( + $.nonWrapAuthority, + $.wrapAuthority.publicKey, + $.mMint.publicKey, + wrapAmount + ); + + // Setup the instruction + const toExtTokenAccount = await $.getATA( + $.extMint.publicKey, + $.nonWrapAuthority.publicKey + ); + + // Cache initial balances + const fromMTokenAccountBalance = await $.getTokenBalance( + fromMTokenAccount + ); + const vaultMTokenAccountBalance = await $.getTokenBalance( + vaultMTokenAccount + ); + const toExtTokenAccountBalance = + variant === Variant.ScaledUiAmount + ? await $.getTokenUiBalance(toExtTokenAccount) + : await $.getTokenBalance(toExtTokenAccount); + + // Send the instruction + await $.ext.methods + .wrap(wrapAmount) + .accounts({ + tokenAuthority: $.wrapAuthority.publicKey, + wrapAuthority: $.ext.programId, + fromMTokenAccount, + toExtTokenAccount, + }) + .signers([$.wrapAuthority]) + .rpc(); + + // Confirm updated balances + await $.expectTokenBalance( + fromMTokenAccount, + fromMTokenAccountBalance.sub(wrapAmount) + ); + await $.expectTokenBalance( + vaultMTokenAccount, + vaultMTokenAccountBalance.add(wrapAmount) + ); + variant === Variant.ScaledUiAmount + ? await $.expectTokenUiBalance( + toExtTokenAccount, + toExtTokenAccountBalance.add(wrapAmount), + Comparison.LessThanOrEqual, + new BN(2) + ) + : await $.expectTokenBalance( + toExtTokenAccount, + toExtTokenAccountBalance.add(wrapAmount) + ); + }); + + // given a wrap authority is not provided + // given the token authority is on the wrap authorities list + // given the from token account has enough M tokens + // given the token authority is the owner of the from M token account + // it transfers the amount of M tokens from the user's M token account to the M vault token account + // it mints the amount of wM tokens to the user's wM token account + test("Wrap to wrap authority account - success", async () => { + // Cache initial balances + const fromMTokenAccountBalance = await $.getTokenBalance( + fromMTokenAccount + ); + const vaultMTokenAccountBalance = await $.getTokenBalance( + vaultMTokenAccount + ); + const toExtTokenAccountBalance = + variant === Variant.ScaledUiAmount + ? await $.getTokenUiBalance(toExtTokenAccount) + : await $.getTokenBalance(toExtTokenAccount); + + const wrapAmount = new BN(randomInt(1, mintAmount.toNumber() + 1)); + + // Send the instruction + await $.ext.methods + .wrap(wrapAmount) + .accountsPartial({ + tokenAuthority: $.wrapAuthority.publicKey, + wrapAuthority: $.ext.programId, + fromMTokenAccount, + toExtTokenAccount, + }) + .signers([$.wrapAuthority]) + .rpc(); + + // Confirm updated balances + await $.expectTokenBalance( + fromMTokenAccount, + fromMTokenAccountBalance.sub(wrapAmount) + ); + await $.expectTokenBalance( + vaultMTokenAccount, + vaultMTokenAccountBalance.add(wrapAmount) + ); + variant === Variant.ScaledUiAmount + ? await $.expectTokenUiBalance( + toExtTokenAccount, + toExtTokenAccountBalance.add(wrapAmount), + Comparison.LessThanOrEqual, + new BN(2) + ) + : await $.expectTokenBalance( + toExtTokenAccount, + toExtTokenAccountBalance.add(wrapAmount) + ); + }); + + // given a wrap authority is not provided + // given the token authority is on the wrap authorities list + // given the from token account has enough M tokens + // given the token authority is the owner of the from M token account + // given the signer does not own the to ext token account + // it transfers the amount of M tokens from the user's M token account to the M vault token account + // it mints the amount of wM tokens to the user's wM token account + test("Wrap to different account - success", async () => { + toExtTokenAccount = await $.getATA( + $.extMint.publicKey, + $.nonWrapAuthority.publicKey + ); + + // Cache initial balances + const fromMTokenAccountBalance = await $.getTokenBalance( + fromMTokenAccount + ); + const vaultMTokenAccountBalance = await $.getTokenBalance( + vaultMTokenAccount + ); + const toExtTokenAccountBalance = + variant === Variant.ScaledUiAmount + ? await $.getTokenUiBalance(toExtTokenAccount) + : await $.getTokenBalance(toExtTokenAccount); + + const wrapAmount = new BN(randomInt(1, mintAmount.toNumber() + 1)); + + // Send the instruction + await $.ext.methods + .wrap(wrapAmount) + .accountsPartial({ + tokenAuthority: $.wrapAuthority.publicKey, + wrapAuthority: $.ext.programId, + fromMTokenAccount, + toExtTokenAccount, + }) + .signers([$.wrapAuthority]) + .rpc(); + + // Confirm updated balances + await $.expectTokenBalance( + fromMTokenAccount, + fromMTokenAccountBalance.sub(wrapAmount) + ); + await $.expectTokenBalance( + vaultMTokenAccount, + vaultMTokenAccountBalance.add(wrapAmount) + ); + variant === Variant.ScaledUiAmount + ? await $.expectTokenUiBalance( + toExtTokenAccount, + toExtTokenAccountBalance.add(wrapAmount), + Comparison.LessThanOrEqual, + new BN(2) + ) + : await $.expectTokenBalance( + toExtTokenAccount, + toExtTokenAccountBalance.add(wrapAmount) + ); + }); + + // given a wrap authority is not provided + // given the token authority is on the wrap authorities list + // given the from token account enough M tokens + // given the token authority is the owner of the from token account + // round-trip (wrap / unwrap) + test("Wrap / unwrap roundtrip - success", async () => { + // Cache the starting balance of M + const startingBalance = await $.getTokenBalance(fromMTokenAccount); + + // Wrap some tokens + const wrapAmount = new BN( + randomInt(1, startingBalance.toNumber() + 1) + ); + await $.wrap($.wrapAuthority, wrapAmount); + + // Unwrap the same amount + await $.unwrap($.wrapAuthority, wrapAmount); + + // Confirm the final balance is the same as the starting balance + $.expectTokenBalance( + fromMTokenAccount, + startingBalance, + Comparison.LessThanOrEqual, + new BN(2) + ); + }); + + // given a wrap authority is provided + // given the wrap authority is not in the wrap authorities list + // it reverts with a NotAuthorized error + test("Wrap authority is not in the wrap authorities list - reverts", async () => { + // Attempt to send the transaction + // Expect revert with a NotAuthorized error + await $.expectAnchorError( + $.ext.methods + .wrap(mintAmount) + .accounts({ + tokenAuthority: $.nonWrapAuthority.publicKey, + wrapAuthority: $.nonWrapAuthority.publicKey, + fromMTokenAccount, + toExtTokenAccount, + }) + .signers([$.nonWrapAuthority]) + .rpc(), + "NotAuthorized" + ); + }); + + // given a wrap authority is provided + // given the wrap authority is in the wrap authorities list + // given the from token account does not have enough M tokens + // it reverts with a ? error + test("Not enough M - wrap authority - reverts", async () => { + const wrapAmount = new BN( + randomInt(mintAmount.toNumber() + 1, 2 ** 48 - 1) + ); + + // Attempt to send the transaction + // Expect an error + await $.expectSystemError( + $.ext.methods + .wrap(wrapAmount) + .accounts({ + tokenAuthority: $.nonWrapAuthority.publicKey, + wrapAuthority: $.wrapAuthority.publicKey, + fromMTokenAccount, + toExtTokenAccount, + }) + .signers([$.nonWrapAuthority, $.wrapAuthority]) + .rpc() + ); + }); + + // given a wrap authority is provided + // given the wrap authority is in the wrap authorities list + // given the from token account has enough M tokens + // given the token authority is not the owner of the from M token account, but is delegated + // it transfers the amount of M tokens from the user's M token account to the M vault token account + // it mints the amount of ext tokens to the user's ext token account + test("Wrap with delegated authority - wrap authority - success", async () => { + const wrapAmount = new BN(randomInt(1, mintAmount.toNumber() + 1)); + + // Approve (delegate) the wrap authority to spend the non-wrap authority's M tokens + const { sourceATA: fromMTokenAccount } = await $.approve( + $.nonWrapAuthority, + $.nonAdmin.publicKey, + $.mMint.publicKey, + wrapAmount + ); + + // Setup the instruction + const toExtTokenAccount = await $.getATA( + $.extMint.publicKey, + $.nonWrapAuthority.publicKey + ); + + // Cache initial balances + const fromMTokenAccountBalance = await $.getTokenBalance( + fromMTokenAccount + ); + const vaultMTokenAccountBalance = await $.getTokenBalance( + vaultMTokenAccount + ); + const toExtTokenAccountBalance = + variant === Variant.ScaledUiAmount + ? await $.getTokenUiBalance(toExtTokenAccount) + : await $.getTokenBalance(toExtTokenAccount); + + // Send the instruction + await $.ext.methods + .wrap(wrapAmount) + .accounts({ + tokenAuthority: $.nonAdmin.publicKey, + wrapAuthority: $.wrapAuthority.publicKey, + fromMTokenAccount, + toExtTokenAccount, + }) + .signers([$.nonAdmin, $.wrapAuthority]) + .rpc(); + + // Confirm updated balances + await $.expectTokenBalance( + fromMTokenAccount, + fromMTokenAccountBalance.sub(wrapAmount) + ); + await $.expectTokenBalance( + vaultMTokenAccount, + vaultMTokenAccountBalance.add(wrapAmount) + ); + variant === Variant.ScaledUiAmount + ? await $.expectTokenUiBalance( + toExtTokenAccount, + toExtTokenAccountBalance.add(wrapAmount), + Comparison.LessThanOrEqual, + new BN(2) + ) + : await $.expectTokenBalance( + toExtTokenAccount, + toExtTokenAccountBalance.add(wrapAmount) + ); + }); + + // given a wrap authority is provided + // given the wrap authority is in the wrap authorities list + // given the from token account has enough M tokens + // given the token authority is the owner of the from M token account + // it transfers the amount of M tokens from the user's M token account to the M vault token account + // it mints the amount of ext tokens to the user's ext token account + test("Wrap to differenct account - wrap authority - success", async () => { + fromMTokenAccount = await $.getATA( + $.mMint.publicKey, + $.nonWrapAuthority.publicKey + ); + + toExtTokenAccount = await $.getATA( + $.extMint.publicKey, + $.nonAdmin.publicKey + ); + + // Cache initial balances + const fromMTokenAccountBalance = await $.getTokenBalance( + fromMTokenAccount + ); + const vaultMTokenAccountBalance = await $.getTokenBalance( + vaultMTokenAccount + ); + const toExtTokenAccountBalance = + variant === Variant.ScaledUiAmount + ? await $.getTokenUiBalance(toExtTokenAccount) + : await $.getTokenBalance(toExtTokenAccount); + + const wrapAmount = new BN(randomInt(1, mintAmount.toNumber() + 1)); + + // Send the instruction + await $.ext.methods + .wrap(wrapAmount) + .accountsPartial({ + tokenAuthority: $.nonWrapAuthority.publicKey, + wrapAuthority: $.wrapAuthority.publicKey, + fromMTokenAccount, + toExtTokenAccount, + }) + .signers([$.nonWrapAuthority, $.wrapAuthority]) + .rpc(); + + // Confirm updated balances + await $.expectTokenBalance( + fromMTokenAccount, + fromMTokenAccountBalance.sub(wrapAmount) + ); + await $.expectTokenBalance( + vaultMTokenAccount, + vaultMTokenAccountBalance.add(wrapAmount) + ); + variant === Variant.ScaledUiAmount + ? await $.expectTokenUiBalance( + toExtTokenAccount, + toExtTokenAccountBalance.add(wrapAmount), + Comparison.LessThanOrEqual, + new BN(2) + ) + : await $.expectTokenBalance( + toExtTokenAccount, + toExtTokenAccountBalance.add(wrapAmount) + ); + }); + + // given a wrap authority is provided + // given the wrap authority is in the wrap authorities list + // given the from token account has enough M tokens + // given the token authority is the owner of the from M token account + // round-trip (wrap / unwrap) + test("Wrap / unwrap roundtrip - wrap authority - success", async () => { + // Cache the starting balance of M + const startingBalance = await $.getTokenBalance(fromMTokenAccount); + + // Wrap some tokens + const wrapAmount = new BN( + randomInt(1, startingBalance.toNumber() + 1) + ); + await $.wrap($.nonWrapAuthority, wrapAmount, $.wrapAuthority); + + // Unwrap the same amount + await $.unwrap($.nonWrapAuthority, wrapAmount, $.wrapAuthority); + + // Confirm the final balance is the same as the starting balance + $.expectTokenBalance( + fromMTokenAccount, + startingBalance, + Comparison.LessThanOrEqual, + new BN(2) + ); + }); + }); + + describe("index different from start (sync required)", () => { + // M Index is strictly increasing + const newIndex = new BN( + randomInt(startIndex.toNumber() + 1, 2e12 + 1) + ); + + beforeEach(async () => { + // Reset the blockhash to avoid issues with duplicate transactions from multiple claim cycles + $.svm.expireBlockhash(); + + // Propagate the new index + await $.propagateIndex(newIndex); + }); + + // test cases + // [x] given the user has no ext tokens to start with + // [X] given no flows before the yield is distributed + // [X] it wraps the amount of M tokens from the user's M token account to the M vault token account + // [X] the user receives the correct amount of ext tokens + // [X] the extension is solvent + // [X] given there are net inflows before the yield is distributed + // [X] it wraps the amount of M tokens from the user's M token account to the M vault token account + // [X] the user receives the correct amount of ext tokens + // [X] the extension is solvent + // [X] given there are net outflows before the yield is distributed + // [X] it wraps the amount of M tokens from the user's M token account to the M vault token account + // [X] the user receives the correct amount of ext tokens + // [X] the extension is solvent + // [X] given the user has ext tokens to start with + // [X] given no flows before the yield is distributed + // [X] it wraps the amount of M tokens from the user's M token account to the M vault token account + // [X] it adjusts the user's existing balance for the new index and then adds the wrap amount + // [X] the extension is solvent + // [X] given there are net inflows before the yield is distributed + // [X] it wraps the amount of M tokens from the user's M token account to the M vault token account + // [X] it adjusts the user's existing balance for the new index and then adds the wrap amount + // [X] the extension is solvent + // [X] given there are net outflows before the yield is distributed + // [X] it wraps the amount of M tokens from the user's M token account to the M vault token account + // [X] it adjusts the user's existing balance for the new index and then adds the wrap amount + // [X] the extension is solvent + + describe("user has no starting balance", () => { + // given no flows before the yield is distributed + // it wraps the amount of M tokens from the user's M token account to the M vault token account + // the extension is solvent + test("Wrap with new index - no flows - success", async () => { + // Mint yield to the m vault for the new index + await $.mClaimFor( + $.getMVault(), + await $.getTokenBalance(vaultMTokenAccount) + ); + await $.mCompleteClaims(); + + // Cache initial balances + const fromMTokenAccountBalance = await $.getTokenBalance( + fromMTokenAccount + ); + const vaultMTokenAccountBalance = await $.getTokenBalance( + vaultMTokenAccount + ); + const toExtTokenAccountBalance = + variant === Variant.ScaledUiAmount + ? await $.getTokenUiBalance(toExtTokenAccount) + : await $.getTokenBalance(toExtTokenAccount); + + const wrapAmount = new BN( + randomInt(1, fromMTokenAccountBalance.toNumber() + 1) + ); + + // Send the instruction + await $.ext.methods + .wrap(wrapAmount) + .accounts({ + tokenAuthority: $.wrapAuthority.publicKey, + wrapAuthority: $.ext.programId, + fromMTokenAccount, + toExtTokenAccount, + }) + .signers([$.wrapAuthority]) + .rpc(); + + // Confirm updated balances + await $.expectTokenBalance( + fromMTokenAccount, + fromMTokenAccountBalance.sub(wrapAmount) + ); + await $.expectTokenBalance( + vaultMTokenAccount, + vaultMTokenAccountBalance.add(wrapAmount) + ); + variant === Variant.ScaledUiAmount + ? await $.expectTokenUiBalance( + toExtTokenAccount, + toExtTokenAccountBalance.add(wrapAmount), + Comparison.LessThanOrEqual, + new BN(2) + ) + : await $.expectTokenBalance( + toExtTokenAccount, + toExtTokenAccountBalance.add(wrapAmount) + ); + + // Confirm the extension is solvent + await $.expectExtSolvent(); + }); + + // given the extension has net inflows before the yield is distributed + // it wraps the amount of M tokens from the user's M token account to the M vault token account + // the extension is solvent + test("Wrap - extension has inflows - success", async () => { + // Mint and wrap additional tokens prior to claim + const inflows = new BN(randomInt(100, mintAmount.toNumber() + 1)); + await $.mintM($.admin.publicKey, inflows); + await $.wrap($.admin, inflows); + + // Mint yield to the m vault for the new index + await $.mClaimFor( + $.getMVault(), + await $.getTokenBalance(vaultMTokenAccount) + ); + await $.mCompleteClaims(); + + // Cache initial balances + const fromMTokenAccountBalance = await $.getTokenBalance( + fromMTokenAccount + ); + const vaultMTokenAccountBalance = await $.getTokenBalance( + vaultMTokenAccount + ); + const toExtTokenAccountBalance = + variant === Variant.ScaledUiAmount + ? await $.getTokenUiBalance(toExtTokenAccount) + : await $.getTokenBalance(toExtTokenAccount); + + const wrapAmount = new BN( + randomInt(1, fromMTokenAccountBalance.toNumber() + 1) + ); + + // Send the instruction + await $.ext.methods + .wrap(wrapAmount) + .accounts({ + tokenAuthority: $.wrapAuthority.publicKey, + wrapAuthority: $.ext.programId, + fromMTokenAccount, + toExtTokenAccount, + }) + .signers([$.wrapAuthority]) + .rpc(); + + // Confirm updated balances + await $.expectTokenBalance( + fromMTokenAccount, + fromMTokenAccountBalance.sub(wrapAmount) + ); + await $.expectTokenBalance( + vaultMTokenAccount, + vaultMTokenAccountBalance.add(wrapAmount) + ); + variant === Variant.ScaledUiAmount + ? await $.expectTokenUiBalance( + toExtTokenAccount, + toExtTokenAccountBalance.add(wrapAmount), + Comparison.LessThanOrEqual, + new BN(2) + ) + : await $.expectTokenBalance( + toExtTokenAccount, + toExtTokenAccountBalance.add(wrapAmount) + ); + // Confirm the extension is solvent + await $.expectExtSolvent(); + }); + + // given the extension has net outflows before the yield is distributed + // it wraps the amount of M tokens from the user's M token account to the M vault token account + // the extension is solvent + test("Wrap - extension has outflows - success", async () => { + // Mint and wrap additional tokens + const outflows = new BN( + randomInt(100, initialWrappedAmount.toNumber() + 1) + ); + await $.unwrap($.admin, outflows); + + // Mint yield to the m vault for the new index + await $.mClaimFor( + $.getMVault(), + await $.getTokenBalance(vaultMTokenAccount) + ); + await $.mCompleteClaims(); + + // Cache initial balances + const fromMTokenAccountBalance = await $.getTokenBalance( + fromMTokenAccount + ); + const vaultMTokenAccountBalance = await $.getTokenBalance( + vaultMTokenAccount + ); + const toExtTokenAccountBalance = + variant === Variant.ScaledUiAmount + ? await $.getTokenUiBalance(toExtTokenAccount) + : await $.getTokenBalance(toExtTokenAccount); + + const wrapAmount = new BN( + randomInt(1, fromMTokenAccountBalance.toNumber() + 1) + ); + + // Send the instruction + await $.ext.methods + .wrap(wrapAmount) + .accounts({ + tokenAuthority: $.wrapAuthority.publicKey, + wrapAuthority: $.ext.programId, + fromMTokenAccount, + toExtTokenAccount, + }) + .signers([$.wrapAuthority]) + .rpc(); + + // Confirm updated balances + await $.expectTokenBalance( + fromMTokenAccount, + fromMTokenAccountBalance.sub(wrapAmount) + ); + await $.expectTokenBalance( + vaultMTokenAccount, + vaultMTokenAccountBalance.add(wrapAmount) + ); + variant === Variant.ScaledUiAmount + ? await $.expectTokenUiBalance( + toExtTokenAccount, + toExtTokenAccountBalance.add(wrapAmount), + Comparison.LessThanOrEqual, + new BN(2) + ) + : await $.expectTokenBalance( + toExtTokenAccount, + toExtTokenAccountBalance.add(wrapAmount) + ); + // Confirm the extension is solvent + await $.expectExtSolvent(); + }); + }); + + describe("user has starting balance", () => { + beforeEach(async () => { + // Give the wrap authority some initial tokens + const initialAmount = new BN( + randomInt(1, mintAmount.toNumber() + 1) + ); + await $.mintM($.wrapAuthority.publicKey, initialAmount); + await $.wrap($.wrapAuthority, initialAmount); + }); + + // given no flows before the yield is distributed + // it adjusts the user's existing balance correctly and then adds the new wrapped amount + // the extension is solvent + test("Wrap with new index - no flows - success", async () => { + const startMultiplier = new BN( + Math.floor((await $.getCurrentMultiplier()) * 1e12) + ); + + // Mint yield to the m vault for the new index + await $.mClaimFor( + $.getMVault(), + await $.getTokenBalance(vaultMTokenAccount) + ); + await $.mCompleteClaims(); + + // Cache initial balances + const fromMTokenAccountBalance = await $.getTokenBalance( + fromMTokenAccount + ); + const vaultMTokenAccountBalance = await $.getTokenBalance( + vaultMTokenAccount + ); + const toExtTokenAccountBalance = + variant === Variant.ScaledUiAmount + ? await $.getTokenUiBalance(toExtTokenAccount) + : await $.getTokenBalance(toExtTokenAccount); + + const wrapAmount = new BN( + randomInt(1, fromMTokenAccountBalance.toNumber() + 1) + ); + + // Send the instruction + await $.ext.methods + .wrap(wrapAmount) + .accounts({ + tokenAuthority: $.wrapAuthority.publicKey, + wrapAuthority: $.ext.programId, + fromMTokenAccount, + toExtTokenAccount, + }) + .signers([$.wrapAuthority]) + .rpc(); + + // Get new multiplier + const newMultiplier = new BN( + Math.floor((await $.getCurrentMultiplier()) * 1e12) + ); + + // Confirm updated balances + await $.expectTokenBalance( + fromMTokenAccount, + fromMTokenAccountBalance.sub(wrapAmount) + ); + await $.expectTokenBalance( + vaultMTokenAccount, + vaultMTokenAccountBalance.add(wrapAmount) + ); + variant === Variant.ScaledUiAmount + ? await $.expectTokenUiBalance( + toExtTokenAccount, + toExtTokenAccountBalance + .mul(newMultiplier) + .div(startMultiplier) + .add(wrapAmount), + Comparison.Equal, + new BN(2) + ) + : await $.expectTokenBalance( + toExtTokenAccount, + toExtTokenAccountBalance.add(wrapAmount) + ); + + // Confirm the extension is solvent + await $.expectExtSolvent(); + }); + + // given the extension has net inflows before the yield is distributed + // it adjusts the user's existing balance correctly and then adds the new wrapped amount + // the extension is solvent + test("Wrap - extension has inflows - success", async () => { + const startMultiplier = new BN( + Math.floor((await $.getCurrentMultiplier()) * 1e12) + ); + + // Mint and wrap additional tokens prior to claim + const inflows = new BN(randomInt(100, mintAmount.toNumber() + 1)); + await $.mintM($.admin.publicKey, inflows); + await $.wrap($.admin, inflows); + + // Mint yield to the m vault for the new index + await $.mClaimFor( + $.getMVault(), + await $.getTokenBalance(vaultMTokenAccount) + ); + await $.mCompleteClaims(); + + // Cache initial balances + const fromMTokenAccountBalance = await $.getTokenBalance( + fromMTokenAccount + ); + const vaultMTokenAccountBalance = await $.getTokenBalance( + vaultMTokenAccount + ); + const toExtTokenAccountBalance = + variant === Variant.ScaledUiAmount + ? await $.getTokenUiBalance(toExtTokenAccount) + : await $.getTokenBalance(toExtTokenAccount); + + const wrapAmount = new BN( + randomInt(1, fromMTokenAccountBalance.toNumber() + 1) + ); + + // Send the instruction + await $.ext.methods + .wrap(wrapAmount) + .accounts({ + tokenAuthority: $.wrapAuthority.publicKey, + wrapAuthority: $.ext.programId, + fromMTokenAccount, + toExtTokenAccount, + }) + .signers([$.wrapAuthority]) + .rpc(); + + // Get new multiplier + const newMultiplier = new BN( + Math.floor((await $.getCurrentMultiplier()) * 1e12) + ); + + // Confirm updated balances + await $.expectTokenBalance( + fromMTokenAccount, + fromMTokenAccountBalance.sub(wrapAmount) + ); + await $.expectTokenBalance( + vaultMTokenAccount, + vaultMTokenAccountBalance.add(wrapAmount) + ); + variant === Variant.ScaledUiAmount + ? await $.expectTokenUiBalance( + toExtTokenAccount, + toExtTokenAccountBalance + .mul(newMultiplier) + .div(startMultiplier) + .add(wrapAmount), + Comparison.Equal, + new BN(2) + ) + : await $.expectTokenBalance( + toExtTokenAccount, + toExtTokenAccountBalance.add(wrapAmount) + ); + // Confirm the extension is solvent + await $.expectExtSolvent(); + }); + + // given the extension has net outflows before the yield is distributed + // it wraps the amount of M tokens from the user's M token account to the M vault token account + // the extension is solvent + test("Wrap - extension has outflows - success", async () => { + const startMultiplier = new BN( + Math.floor((await $.getCurrentMultiplier()) * 1e12) + ); + + // Mint and wrap additional tokens + const outflows = new BN( + randomInt(100, initialWrappedAmount.toNumber() + 1) + ); + await $.unwrap($.admin, outflows); + + // Mint yield to the m vault for the new index + await $.mClaimFor( + $.getMVault(), + await $.getTokenBalance(vaultMTokenAccount) + ); + await $.mCompleteClaims(); + + // Cache initial balances + const fromMTokenAccountBalance = await $.getTokenBalance( + fromMTokenAccount + ); + const vaultMTokenAccountBalance = await $.getTokenBalance( + vaultMTokenAccount + ); + const toExtTokenAccountBalance = + variant === Variant.ScaledUiAmount + ? await $.getTokenUiBalance(toExtTokenAccount) + : await $.getTokenBalance(toExtTokenAccount); + + const wrapAmount = new BN( + randomInt(1, fromMTokenAccountBalance.toNumber() + 1) + ); + + // Send the instruction + await $.ext.methods + .wrap(wrapAmount) + .accounts({ + tokenAuthority: $.wrapAuthority.publicKey, + wrapAuthority: $.ext.programId, + fromMTokenAccount, + toExtTokenAccount, + }) + .signers([$.wrapAuthority]) + .rpc(); + + // Get new multiplier + const newMultiplier = new BN( + Math.floor((await $.getCurrentMultiplier()) * 1e12) + ); + + // Confirm updated balances + await $.expectTokenBalance( + fromMTokenAccount, + fromMTokenAccountBalance.sub(wrapAmount) + ); + await $.expectTokenBalance( + vaultMTokenAccount, + vaultMTokenAccountBalance.add(wrapAmount) + ); + variant === Variant.ScaledUiAmount + ? await $.expectTokenUiBalance( + toExtTokenAccount, + toExtTokenAccountBalance + .mul(newMultiplier) + .div(startMultiplier) + .add(wrapAmount), + Comparison.Equal, + new BN(2) + ) + : await $.expectTokenBalance( + toExtTokenAccount, + toExtTokenAccountBalance.add(wrapAmount) + ); + // Confirm the extension is solvent + await $.expectExtSolvent(); + }); + }); + }); + }); + + describe("unwrap unit tests", () => { + const wrappedAmount = new BN(25_000_000); + + let fromExtTokenAccount: PublicKey; + let toMTokenAccount: PublicKey; + + beforeEach(async () => { + fromExtTokenAccount = await $.getATA( + $.extMint.publicKey, + $.wrapAuthority.publicKey + ); + toMTokenAccount = await $.getATA( + $.mMint.publicKey, + $.wrapAuthority.publicKey + ); + + // Wrap tokens for the users so we can test unwrapping + await $.wrap($.wrapAuthority, wrappedAmount); + await $.wrap($.nonWrapAuthority, wrappedAmount, $.wrapAuthority); + }); + describe("index same as start", () => { + // test cases + // [X] given the m mint account does not match the one stored in the global account + // [X] it reverts with an InvalidAccount error + // [X] given the ext mint account does not match the one stored in the global account + // [X] it reverts with an InvalidAccount error + // [X] given the token authority is not the authority on the from ext token account and is not delegated by the owner + // [X] it reverts with a Token program error + // [X] given the vault M token account is not the M Vaults ATA for the M token mint + // [X] it reverts with a ConstraintAssociated error + // [X] given the to m token account is for the wrong mint + // [X] it reverts with a ConstraintTokenMint error + // [X] given the from ext token account is for the wrong mint + // [X] it reverts with a ConstraintTokenMint error + // [X] given a wrap authority is not provided + // [X] given the token authority is not in the wrap authorities list + // [X] it reverts with a NotAuthorized error + // [X] given the token authority is in the wrap authorities list + // [X] given the from token account does not have enough ext tokens + // [X] it unwraps the from token accounts whole balance + // [X] given the from token account has enough ext tokens + // [X] given the token authority is not the owner of the from ext token account, but is delegated + // [X] it burns the amount of ext tokens from the from's ext token account + // [X] given the token authority is the owner of the from ext token account + // [X] it burns the amount of ext tokens from the from's ext token account + // [X] it transfers the amount of M tokens from the M vault token account to the to's M token account + // [X] given a wrap authority is provided + // [X] given the wrap authority does not sign the transaction + // [X] it reverts + // [X] given the wrap authority is not on the wrap authorities list + // [X] it reverts with a NotAuthorized error + // [X] given the wrap authority is on the wrap authorities list + // [X] given the from token account does not have enough ext tokens + // [X] it reverts + // [X] given the from token account has enough ext tokens + // [X] given the token authority is not the owner of the from ext token account, but is delegated + // [X] it burns the amount of ext tokens from the from's ext token account + // [X] given the token authority is the owner of the from ext token account + // [X] it burns the amount of ext tokens from the from's ext token account + // [X] it transfers the amount of M tokens from the M vault token account to the to's M token account + + // given the m mint account does not match the one stored in the global account + // it reverts with an InvalidAccount error + test("M mint account does not match global account - reverts", async () => { + const wrongMint = Keypair.generate(); + await $.createMint(wrongMint, $.wrapAuthority.publicKey, true, 6); + + // Update the M token accounts + toMTokenAccount = await $.getATA( + wrongMint.publicKey, + $.wrapAuthority.publicKey + ); + vaultMTokenAccount = await $.getATA( + wrongMint.publicKey, + $.getMVault() + ); + + // Attempt to send the transaction + // Expect an invalid account error -> becomes an AccountNotInitialized error because you can't create an earner account for the wrong mint + await $.expectAnchorError( + $.ext.methods + .unwrap(wrappedAmount) + .accountsPartial({ + tokenAuthority: $.wrapAuthority.publicKey, + unwrapAuthority: $.ext.programId, + mMint: wrongMint.publicKey, + fromExtTokenAccount, + toMTokenAccount, + vaultMTokenAccount, + }) + .signers([$.wrapAuthority]) + .rpc(), + "AccountNotInitialized" + ); + }); + + // given the ext mint account does not match the one stored in the global account + // it reverts with an InvalidAccount error + test("Ext mint account does not match global account - reverts", async () => { + const wrongMint = Keypair.generate(); + await $.createMint(wrongMint, $.wrapAuthority.publicKey, true, 6); + + // Update the ext token accounts + fromExtTokenAccount = await $.getATA( + wrongMint.publicKey, + $.wrapAuthority.publicKey + ); + + // Attempt to send the transaction + // Expect an invalid account error + await $.expectAnchorError( + $.ext.methods + .unwrap(wrappedAmount) + .accountsPartial({ + tokenAuthority: $.wrapAuthority.publicKey, + unwrapAuthority: $.ext.programId, + extMint: wrongMint.publicKey, + fromExtTokenAccount, + toMTokenAccount, + }) + .signers([$.wrapAuthority]) + .rpc(), + "InvalidAccount" + ); + }); + + // given the token authority is not the authority on the from ext token account and not delegated + // it reverts with a ConstraintTokenOwner error + test("Token authority is not the authority on the from Ext token account and not delegated - reverts", async () => { + // Get the ATA for another user + fromExtTokenAccount = await $.getATA( + $.extMint.publicKey, + $.nonWrapAuthority.publicKey + ); + + // Attempt to send the transaction + // Expect revert with TokenOwner error + await $.expectSystemError( + $.ext.methods + .unwrap(wrappedAmount) + .accounts({ + tokenAuthority: $.wrapAuthority.publicKey, + unwrapAuthority: $.ext.programId, + fromExtTokenAccount, + toMTokenAccount, + }) + .signers([$.wrapAuthority]) + .rpc() + ); + }); + + // given the M vault token account is not the M vault PDA's ATA + // it reverts with a ConstraintAssociated error + test("M Vault Token account is the the M Vault PDA's ATA (other token account) - reverts", async () => { + // Create a token account for the M vault that is not the ATA + const mVault = $.getMVault(); + const { tokenAccount: vaultMTokenAccount } = + await $.createTokenAccount( + $.mMint.publicKey, + $.getMVault(), + true, + true + ); + + // Remove the existing M earner account and create a new one for this token account + await $.removeMEarner(mVault); + const mEarnerAccount = await $.addMEarner( + mVault, + vaultMTokenAccount + ); + + // Attempt to send the transaction + // Expect revert with a ConstraintAssociated error + await $.expectAnchorError( + $.ext.methods + .unwrap(wrappedAmount) + .accountsPartial({ + tokenAuthority: $.wrapAuthority.publicKey, + unwrapAuthority: $.ext.programId, + fromExtTokenAccount, + toMTokenAccount, + vaultMTokenAccount, + mEarnerAccount, + }) + .signers([$.wrapAuthority]) + .rpc(), + "ConstraintAssociated" + ); + }); + + // given the user m token account is for the wrong mint + // it reverts with a ConstraintTokenMint error + test("To M token account is for wrong mint - reverts", async () => { + // Attempt to send the transaction + // Expect revert with a ConstraintTokenMint error + await $.expectAnchorError( + $.ext.methods + .unwrap(wrappedAmount) + .accounts({ + tokenAuthority: $.wrapAuthority.publicKey, + unwrapAuthority: $.ext.programId, + toMTokenAccount: fromExtTokenAccount, + fromExtTokenAccount, + }) + .signers([$.wrapAuthority]) + .rpc(), + "ConstraintTokenMint" + ); + }); + + // given the user ext token account is for the wrong mint + // it reverts with a ConstraintTokenMint error + test("From Ext token account is for the wrong mint - reverts", async () => { + // Attempt to send the transaction + // Expect revert with a ConstraintTokenMint error + await $.expectAnchorError( + $.ext.methods + .unwrap(wrappedAmount) + .accounts({ + tokenAuthority: $.wrapAuthority.publicKey, + unwrapAuthority: $.ext.programId, + fromExtTokenAccount: toMTokenAccount, + toMTokenAccount, + }) + .signers([$.wrapAuthority]) + .rpc(), + "ConstraintTokenMint" + ); + }); + // given a wrap authority is not provided + // given the token authority is not in the wrap authorities list + // it reverts with a NotAuthorized error + test("Token authority is not in the wrap authorities list - reverts", async () => { + fromExtTokenAccount = await $.getATA( + $.extMint.publicKey, + $.nonWrapAuthority.publicKey + ); + toMTokenAccount = await $.getATA( + $.mMint.publicKey, + $.nonWrapAuthority.publicKey + ); + // Attempt to send the transaction + // Expect revert with a NotAuthorized error + await $.expectAnchorError( + $.ext.methods + .unwrap(wrappedAmount) + .accounts({ + tokenAuthority: $.nonWrapAuthority.publicKey, + unwrapAuthority: $.ext.programId, + fromExtTokenAccount, + toMTokenAccount, + }) + .signers([$.nonWrapAuthority]) + .rpc(), + "NotAuthorized" + ); + }); + + // given a wrap authority is not provided + // given the token authority is in the wrap authorities list + // give the from token account does not have enough ext tokens + // it unwraps the from token account's total balance of ext tokens + test("Not enough ext tokens, unwraps user's total balance - success", async () => { + // Get the balance of the from ext token account + const fromExtTokenAccountBalance = + variant === Variant.ScaledUiAmount + ? await $.getTokenUiBalance( + fromExtTokenAccount, + await $.getCurrentMultiplier() + ) + : await $.getTokenBalance(fromExtTokenAccount); + + vaultMTokenAccount = await $.getATA( + $.mMint.publicKey, + $.getMVault() + ); + const vaultMTokenAccountBalance = await $.getTokenBalance( + vaultMTokenAccount + ); + const toMTokenAccountBalance = await $.getTokenBalance( + toMTokenAccount + ); + + // Create a random amount to unwrap that is greater than the balance + const unwrapAmount = new BN( + randomInt(fromExtTokenAccountBalance.toNumber() + 1, 2 ** 48 - 1) + ); + + // Send the unwrap + await $.ext.methods + .unwrap(unwrapAmount) + .accounts({ + tokenAuthority: $.wrapAuthority.publicKey, + unwrapAuthority: $.ext.programId, + fromExtTokenAccount, + toMTokenAccount, + }) + .signers([$.wrapAuthority]) + .rpc(); + + $.expectTokenBalance(fromExtTokenAccount, new BN(0)); + $.expectTokenBalance( + vaultMTokenAccount, + vaultMTokenAccountBalance.sub(fromExtTokenAccountBalance), + Comparison.GreaterThanOrEqual, + new BN(2) + ); + $.expectTokenBalance( + toMTokenAccount, + toMTokenAccountBalance.add(fromExtTokenAccountBalance), + Comparison.LessThanOrEqual, + new BN(2) + ); + }); + + // given a wrap authority is not provided + // given the token authority is in the wrap authorities list + // given the from token account has enough ext tokens + // given the token authority is not the owner of the from ext token account, but is delegated + // it burns the amount of ext tokens from the from's ext token account + // it transfers the amount of M tokens from the M vault token account to the to's M token account + test("Unwrap with delegated authority - success", async () => { + const unwrapAmount = new BN( + randomInt(1, wrappedAmount.toNumber() + 1) + ); + + // Approve (delegate) the wrap authority to spend the non-wrap authority's ext tokens + const { sourceATA: fromExtTokenAccount } = await $.approve( + $.nonWrapAuthority, + $.wrapAuthority.publicKey, + $.extMint.publicKey, + unwrapAmount + ); + toMTokenAccount = await $.getATA( + $.mMint.publicKey, + $.nonWrapAuthority.publicKey + ); + + // Cache initial balances + const fromExtTokenAccountBalance = + variant === Variant.ScaledUiAmount + ? await $.getTokenUiBalance(fromExtTokenAccount) + : await $.getTokenBalance(fromExtTokenAccount); + const vaultMTokenAccountBalance = await $.getTokenBalance( + vaultMTokenAccount + ); + const toMTokenAccountBalance = await $.getTokenBalance( + toMTokenAccount + ); + + // Send the instruction + await $.ext.methods + .unwrap(unwrapAmount) + .accounts({ + tokenAuthority: $.wrapAuthority.publicKey, + unwrapAuthority: $.ext.programId, + fromExtTokenAccount, + toMTokenAccount, + }) + .signers([$.wrapAuthority]) + .rpc(); + + // Confirm updated balances + variant === Variant.ScaledUiAmount + ? await $.expectTokenUiBalance( + fromExtTokenAccount, + fromExtTokenAccountBalance.sub(unwrapAmount), + Comparison.LessThanOrEqual, + new BN(2) + ) + : await $.expectTokenBalance( + fromExtTokenAccount, + fromExtTokenAccountBalance.sub(unwrapAmount) + ); + await $.expectTokenBalance( + vaultMTokenAccount, + vaultMTokenAccountBalance.sub(unwrapAmount), + Comparison.GreaterThanOrEqual, + new BN(2) + ); + await $.expectTokenBalance( + toMTokenAccount, + toMTokenAccountBalance.add(unwrapAmount), + Comparison.LessThanOrEqual, + new BN(2) + ); + }); + + // given a wrap authority is not provided + // given the token authority is in the wrap authorities list + // given the from token account has enough ext tokens + // it transfers the amount of M tokens from the M vault token account to the user's M token account + // it burns the amount of ext tokens from the user's ext token account + test("Unwrap to wrap authority account - success", async () => { + // Cache initial balances + const fromExtTokenAccountBalance = + variant === Variant.ScaledUiAmount + ? await $.getTokenUiBalance(fromExtTokenAccount) + : await $.getTokenBalance(fromExtTokenAccount); + const vaultMTokenAccountBalance = await $.getTokenBalance( + vaultMTokenAccount + ); + const toMTokenAccountBalance = await $.getTokenBalance( + toMTokenAccount + ); + + const unwrapAmount = new BN( + randomInt(1, wrappedAmount.toNumber() + 1) + ); + + // Send the instruction + await $.ext.methods + .unwrap(unwrapAmount) + .accountsPartial({ + tokenAuthority: $.wrapAuthority.publicKey, + unwrapAuthority: $.ext.programId, + fromExtTokenAccount, + toMTokenAccount, + }) + .signers([$.wrapAuthority]) + .rpc(); + + // Confirm updated balances + await $.expectTokenBalance( + toMTokenAccount, + toMTokenAccountBalance.add(unwrapAmount), + Comparison.LessThanOrEqual, + new BN(2) + ); + await $.expectTokenBalance( + vaultMTokenAccount, + vaultMTokenAccountBalance.sub(unwrapAmount), + Comparison.GreaterThanOrEqual, + new BN(2) + ); + variant === Variant.ScaledUiAmount + ? await $.expectTokenUiBalance( + fromExtTokenAccount, + fromExtTokenAccountBalance.sub(unwrapAmount), + Comparison.LessThanOrEqual, + new BN(2) + ) + : await $.expectTokenBalance( + fromExtTokenAccount, + fromExtTokenAccountBalance.sub(unwrapAmount) + ); + }); + + // given a wrap authority is not provided + // given the token authority is in the wrap authorities list + // given the from token account has enough ext tokens + // it transfers the amount of M tokens from the M vault token account to the to M token account + // it burns the amount of ext tokens from the from ext token account + test("Unwrap to different account - success", async () => { + toMTokenAccount = await $.getATA( + $.mMint.publicKey, + $.nonWrapAuthority.publicKey + ); + + // Cache initial balances + const fromExtTokenAccountBalance = + variant === Variant.ScaledUiAmount + ? await $.getTokenUiBalance(fromExtTokenAccount) + : await $.getTokenBalance(fromExtTokenAccount); + const vaultMTokenAccountBalance = await $.getTokenBalance( + vaultMTokenAccount + ); + const toMTokenAccountBalance = await $.getTokenBalance( + toMTokenAccount + ); + + const unwrapAmount = new BN( + randomInt(1, wrappedAmount.toNumber() + 1) + ); + + // Send the instruction + await $.ext.methods + .unwrap(unwrapAmount) + .accounts({ + tokenAuthority: $.wrapAuthority.publicKey, + unwrapAuthority: $.ext.programId, + fromExtTokenAccount, + toMTokenAccount, + }) + .signers([$.wrapAuthority]) + .rpc(); + + // Confirm updated balances + await $.expectTokenBalance( + toMTokenAccount, + toMTokenAccountBalance.add(unwrapAmount), + Comparison.LessThanOrEqual, + new BN(2) + ); + await $.expectTokenBalance( + vaultMTokenAccount, + vaultMTokenAccountBalance.sub(unwrapAmount), + Comparison.GreaterThanOrEqual, + new BN(2) + ); + variant === Variant.ScaledUiAmount + ? await $.expectTokenUiBalance( + fromExtTokenAccount, + fromExtTokenAccountBalance.sub(unwrapAmount), + Comparison.LessThanOrEqual, + new BN(2) + ) + : await $.expectTokenBalance( + fromExtTokenAccount, + fromExtTokenAccountBalance.sub(unwrapAmount) + ); + }); + + // given a wrap authority is provided + // given the wrap authority does not sign the transaction + // it reverts + test("Wrap authority does not sign - reverts", async () => { + fromExtTokenAccount = await $.getATA( + $.extMint.publicKey, + $.nonWrapAuthority.publicKey + ); + toMTokenAccount = await $.getATA( + $.mMint.publicKey, + $.nonWrapAuthority.publicKey + ); + + // Attempt to send the transaction + // Expect revert + await $.expectSystemError( + $.ext.methods + .unwrap(wrappedAmount) + .accounts({ + tokenAuthority: $.nonWrapAuthority.publicKey, + unwrapAuthority: $.wrapAuthority.publicKey, + fromExtTokenAccount, + toMTokenAccount, + }) + .signers([$.nonWrapAuthority]) + .rpc() + ); + }); + + // given a wrap authority is provided + // given the wrap authority is not on the wrap authorities list + // it reverts with a NotAuthorized error + test("Wrap authority not on wrap authorities list - reverts", async () => { + fromExtTokenAccount = await $.getATA( + $.extMint.publicKey, + $.nonWrapAuthority.publicKey + ); + toMTokenAccount = await $.getATA( + $.mMint.publicKey, + $.nonWrapAuthority.publicKey + ); + + // Attempt to send the transaction + // Expect revert with a NotAuthorized error + await $.expectAnchorError( + $.ext.methods + .unwrap(wrappedAmount) + .accounts({ + tokenAuthority: $.nonWrapAuthority.publicKey, + unwrapAuthority: $.nonAdmin.publicKey, + fromExtTokenAccount, + toMTokenAccount, + }) + .signers([$.nonWrapAuthority, $.nonAdmin]) + .rpc(), + "NotAuthorized" + ); + }); + + // given a wrap authority is provided + // given the wrap authority is on the wrap authorities list + // given the from token account does not have enough ext tokens + // it unwraps the from token account's total balance of ext tokens + test("Not enough ext tokens, unwraps user's total balance - wrap authority - success", async () => { + fromExtTokenAccount = await $.getATA( + $.extMint.publicKey, + $.nonWrapAuthority.publicKey + ); + toMTokenAccount = await $.getATA( + $.mMint.publicKey, + $.nonWrapAuthority.publicKey + ); + vaultMTokenAccount = await $.getATA( + $.mMint.publicKey, + $.getMVault() + ); + + // Get the balance of the from ext token account + const fromExtTokenAccountBalance = + variant === Variant.ScaledUiAmount + ? await $.getTokenUiBalance( + fromExtTokenAccount, + await $.getCurrentMultiplier() + ) + : await $.getTokenBalance(fromExtTokenAccount); + const vaultMTokenAccountBalance = await $.getTokenBalance( + vaultMTokenAccount + ); + const toMTokenAccountBalance = await $.getTokenBalance( + toMTokenAccount + ); + + const unwrapAmount = new BN( + randomInt(fromExtTokenAccountBalance.toNumber() + 1, 2 ** 48 - 1) + ); + + // Send the unwrap + await $.ext.methods + .unwrap(unwrapAmount) + .accounts({ + tokenAuthority: $.nonWrapAuthority.publicKey, + unwrapAuthority: $.wrapAuthority.publicKey, + fromExtTokenAccount, + toMTokenAccount, + }) + .signers([$.nonWrapAuthority, $.wrapAuthority]) + .rpc(); + + $.expectTokenBalance(fromExtTokenAccount, new BN(0)); + $.expectTokenBalance( + vaultMTokenAccount, + vaultMTokenAccountBalance.sub(fromExtTokenAccountBalance), + Comparison.GreaterThanOrEqual, + new BN(2) + ); + $.expectTokenBalance( + toMTokenAccount, + toMTokenAccountBalance.add(fromExtTokenAccountBalance), + Comparison.LessThanOrEqual, + new BN(2) + ); + }); + + // given a wrap authority is provided + // given the wrap authority is on the wrap authorities list + // given the from token account has enough ext tokens + // given the token authority is not the owner of the from ext token account, but is delegated + // it burns the amount of ext tokens from the from's ext token account + // it transfers the amount of M tokens from the M vault token account to the to's M token account + test("Unwrap with delegated authority - wrap authority - success", async () => { + vaultMTokenAccount = await $.getATA( + $.mMint.publicKey, + $.getMVault() + ); + toMTokenAccount = await $.getATA( + $.mMint.publicKey, + $.nonWrapAuthority.publicKey + ); + + // Approve (delegate) the nonAdmin to spend the non-wrap authority's ext tokens + const { sourceATA: fromExtTokenAccount } = await $.approve( + $.nonWrapAuthority, + $.nonAdmin.publicKey, + $.extMint.publicKey, + wrappedAmount + ); + + // Cache initial balances + const fromExtTokenAccountBalance = + variant === Variant.ScaledUiAmount + ? await $.getTokenUiBalance(fromExtTokenAccount) + : await $.getTokenBalance(fromExtTokenAccount); + const vaultMTokenAccountBalance = await $.getTokenBalance( + vaultMTokenAccount + ); + const toMTokenAccountBalance = await $.getTokenBalance( + toMTokenAccount + ); + + const unwrapAmount = new BN( + randomInt(1, wrappedAmount.toNumber() + 1) + ); + + // Send the instruction + await $.ext.methods + .unwrap(unwrapAmount) + .accounts({ + tokenAuthority: $.nonAdmin.publicKey, + unwrapAuthority: $.wrapAuthority.publicKey, + fromExtTokenAccount, + toMTokenAccount, + }) + .signers([$.nonAdmin, $.wrapAuthority]) + .rpc(); + }); + + // given a wrap authority is provided + // given the wrap authority is on the wrap authorities list + // given the from token account has enough ext tokens + // given the token authority is the owner of the from ext token account + // it burns the amount of ext tokens from the from's ext token account + // it transfers the amount of M tokens from the M vault token account to the to's M token account + test("Unwrap with owner authority - wrap authority - success", async () => { + fromExtTokenAccount = await $.getATA( + $.extMint.publicKey, + $.nonWrapAuthority.publicKey + ); + toMTokenAccount = await $.getATA( + $.mMint.publicKey, + $.nonWrapAuthority.publicKey + ); + vaultMTokenAccount = await $.getATA( + $.mMint.publicKey, + $.getMVault() + ); + + // Cache initial balances + const fromExtTokenAccountBalance = + variant === Variant.ScaledUiAmount + ? await $.getTokenUiBalance(fromExtTokenAccount) + : await $.getTokenBalance(fromExtTokenAccount); + const vaultMTokenAccountBalance = await $.getTokenBalance( + vaultMTokenAccount + ); + const toMTokenAccountBalance = await $.getTokenBalance( + toMTokenAccount + ); + + const unwrapAmount = new BN( + randomInt(1, wrappedAmount.toNumber() + 1) + ); + + // Send the instruction + await $.ext.methods + .unwrap(unwrapAmount) + .accounts({ + tokenAuthority: $.nonWrapAuthority.publicKey, + unwrapAuthority: $.wrapAuthority.publicKey, + fromExtTokenAccount, + toMTokenAccount, + }) + .signers([$.nonWrapAuthority, $.wrapAuthority]) + .rpc(); + + // Confirm updated balances + await $.expectTokenBalance( + toMTokenAccount, + toMTokenAccountBalance.add(unwrapAmount), + Comparison.LessThanOrEqual, + new BN(2) + ); + await $.expectTokenBalance( + vaultMTokenAccount, + vaultMTokenAccountBalance.sub(unwrapAmount), + Comparison.GreaterThanOrEqual, + new BN(2) + ); + variant === Variant.ScaledUiAmount + ? await $.expectTokenUiBalance( + fromExtTokenAccount, + fromExtTokenAccountBalance.sub(unwrapAmount), + Comparison.LessThanOrEqual, + new BN(2) + ) + : await $.expectTokenBalance( + fromExtTokenAccount, + fromExtTokenAccountBalance.sub(unwrapAmount) + ); + }); + }); + + describe("index different from start", () => { + const newIndex = new BN( + randomInt(startIndex.toNumber() + 1, 2e12 + 1) + ); + let newMultiplier: number = 1.0; + + beforeEach(async () => { + // Reset the blockhash to avoid issues with duplicate transactions from multiple claim cycles + $.svm.expireBlockhash(); + + // Propagate the new index + await $.propagateIndex(newIndex); + + // Calculate the expected multipler after the new index push + if (variant === Variant.ScaledUiAmount) { + newMultiplier = await $.getNewMultiplier(newIndex); + } + }); + + // test cases + // [X] given there are no flows before the yield is distributed + // [X] it unwraps the amount of M tokens from the M vault token account to the user's M token account + // [X] it burns the correct amount of ext tokens from the user's ext token account + // [X] the extension is solvent + // [X] given there are net inflows before the yield is distributed + // [X] it unwraps the amount of M tokens from the M vault token account to the user's M token account + // [X] it unwraps the correct amount of ext tokens from the user's ext token account + // [X] the extension is solvent + // [X] given there are net outflows before the yield is distributed + // [X] it unwraps the amount of M tokens from the M vault token account to the user's M token account + // [X] it unwraps the correct amount of ext tokens from the user's ext token account + // [X] the extension is solvent + + // given yield has been minted to the m vault for the new index + // it unwraps the amount of M tokens from the M vault token account to the user's M token account + test("Unwrap with new index - no flows - success", async () => { + // Mint yield to the m vault for the new index + await $.mClaimFor( + $.getMVault(), + await $.getTokenBalance(vaultMTokenAccount) + ); + await $.mCompleteClaims(); + + // Cache initial balances + const vaultMTokenAccountBalance = await $.getTokenBalance( + vaultMTokenAccount + ); + const toMTokenAccountBalance = await $.getTokenBalance( + toMTokenAccount + ); + const fromExtTokenAccountBalance = + variant === Variant.ScaledUiAmount + ? await $.getTokenUiBalance(fromExtTokenAccount, newMultiplier) + : await $.getTokenBalance(fromExtTokenAccount); + + const unwrapAmount = new BN( + randomInt(1, fromExtTokenAccountBalance.toNumber() + 1) + ); + + // Send the instruction + await $.ext.methods + .unwrap(unwrapAmount) + .accounts({ + tokenAuthority: $.wrapAuthority.publicKey, + unwrapAuthority: $.ext.programId, + fromExtTokenAccount, + toMTokenAccount, + }) + .signers([$.wrapAuthority]) + .rpc(); + + // Confirm updated balances + await $.expectTokenBalance( + toMTokenAccount, + toMTokenAccountBalance.add(unwrapAmount), + Comparison.LessThanOrEqual, + new BN(2) + ); + await $.expectTokenBalance( + vaultMTokenAccount, + vaultMTokenAccountBalance.sub(unwrapAmount), + Comparison.GreaterThanOrEqual, + new BN(2) + ); + variant === Variant.ScaledUiAmount + ? await $.expectTokenUiBalance( + fromExtTokenAccount, + fromExtTokenAccountBalance.sub(unwrapAmount), + Comparison.LessThanOrEqual, + new BN(2) + ) + : await $.expectTokenBalance( + fromExtTokenAccount, + fromExtTokenAccountBalance.sub(unwrapAmount) + ); + + // Confirm the extension is solvent + await $.expectExtSolvent(); + }); + + // given there are net inflows before the yield is distributed + // it unwraps the amount of M tokens from the M vault token account to the user's M token account + // it unwraps the correct amount of ext tokens from the user's ext token account + // the extension is solvent + test("Unwrap - extension has inflows - success", async () => { + // Mint and wrap additional tokens prior to claim + const inflows = new BN( + randomInt(100, wrappedAmount.toNumber() + 1) + ); + await $.mintM($.admin.publicKey, inflows); + await $.wrap($.admin, inflows); + + // Mint yield to the m vault for the new index + await $.mClaimFor( + $.getMVault(), + await $.getTokenBalance(vaultMTokenAccount) + ); + await $.mCompleteClaims(); + + // Cache initial balances + const vaultMTokenAccountBalance = await $.getTokenBalance( + vaultMTokenAccount + ); + const toMTokenAccountBalance = await $.getTokenBalance( + toMTokenAccount + ); + const fromExtTokenAccountBalance = + variant === Variant.ScaledUiAmount + ? await $.getTokenUiBalance(fromExtTokenAccount, newMultiplier) + : await $.getTokenBalance(fromExtTokenAccount); + + const unwrapAmount = new BN( + randomInt(1, fromExtTokenAccountBalance.toNumber() + 1) + ); + + // Send the instruction + await $.ext.methods + .unwrap(unwrapAmount) + .accounts({ + tokenAuthority: $.wrapAuthority.publicKey, + unwrapAuthority: $.ext.programId, + fromExtTokenAccount, + toMTokenAccount, + }) + .signers([$.wrapAuthority]) + .rpc(); + + // Confirm updated balances + await $.expectTokenBalance( + toMTokenAccount, + toMTokenAccountBalance.add(unwrapAmount), + Comparison.LessThanOrEqual, + new BN(2) + ); + await $.expectTokenBalance( + vaultMTokenAccount, + vaultMTokenAccountBalance.sub(unwrapAmount), + Comparison.GreaterThanOrEqual, + new BN(2) + ); + variant === Variant.ScaledUiAmount + ? await $.expectTokenUiBalance( + fromExtTokenAccount, + fromExtTokenAccountBalance.sub(unwrapAmount), + Comparison.LessThanOrEqual, + new BN(2) + ) + : await $.expectTokenBalance( + fromExtTokenAccount, + fromExtTokenAccountBalance.sub(unwrapAmount) + ); + + // Confirm the extension is solvent + await $.expectExtSolvent(); + }); + + // given there are net outflows before the yield is distributed + // it unwraps the amount of M tokens from the M vault token account to the user's M token account + // it unwraps the correct amount of ext tokens from the user's ext token account + // the extension is solvent + test("Unwrap - extension has outflows - success", async () => { + // Unwrap tokens prior to claim + const outflows = new BN( + randomInt(100, initialWrappedAmount.toNumber() + 1) + ); + await $.unwrap($.admin, outflows); + + // Mint yield to the m vault for the new index + await $.mClaimFor( + $.getMVault(), + await $.getTokenBalance(vaultMTokenAccount) + ); + await $.mCompleteClaims(); + + // Cache initial balances + const vaultMTokenAccountBalance = await $.getTokenBalance( + vaultMTokenAccount + ); + const toMTokenAccountBalance = await $.getTokenBalance( + toMTokenAccount + ); + const fromExtTokenAccountBalance = + variant === Variant.ScaledUiAmount + ? await $.getTokenUiBalance(fromExtTokenAccount, newMultiplier) + : await $.getTokenBalance(fromExtTokenAccount); + + const unwrapAmount = new BN( + randomInt(1, fromExtTokenAccountBalance.toNumber() + 1) + ); + + // Send the instruction + await $.ext.methods + .unwrap(unwrapAmount) + .accounts({ + tokenAuthority: $.wrapAuthority.publicKey, + unwrapAuthority: $.ext.programId, + fromExtTokenAccount, + toMTokenAccount, + }) + .signers([$.wrapAuthority]) + .rpc(); + + // Confirm updated balances + await $.expectTokenBalance( + toMTokenAccount, + toMTokenAccountBalance.add(unwrapAmount), + Comparison.LessThanOrEqual, + new BN(2) + ); + await $.expectTokenBalance( + vaultMTokenAccount, + vaultMTokenAccountBalance.sub(unwrapAmount), + Comparison.GreaterThanOrEqual, + new BN(2) + ); + variant === Variant.ScaledUiAmount + ? await $.expectTokenUiBalance( + fromExtTokenAccount, + fromExtTokenAccountBalance.sub(unwrapAmount), + Comparison.LessThanOrEqual, + new BN(2) + ) + : await $.expectTokenBalance( + fromExtTokenAccount, + fromExtTokenAccountBalance.sub(unwrapAmount) + ); + + // Confirm the extension is solvent + await $.expectExtSolvent(); + }); + }); + }); + }); + + if (variant !== Variant.NoYield) { + describe("open instruction tests", () => { + describe("sync unit tests", () => { + const initialWrappedAmount = new BN(10_000_000); // 10 with 6 decimals + + let wrapAuthorities: PublicKey[]; + let vaultMTokenAccount: PublicKey; + const feeBps = new BN(randomInt(10000)); + + const startIndex = new BN( + randomInt(initialIndex.toNumber() + 1, 2e12) + ); + + // Setup accounts with M tokens so we can test wrapping and unwrapping + beforeEach(async () => { + wrapAuthorities = [$.admin.publicKey, $.wrapAuthority.publicKey]; + vaultMTokenAccount = await $.getATA( + $.mMint.publicKey, + $.getMVault() + ); + + // Initialize the extension program + await $.initializeExt(wrapAuthorities, feeBps); + + // Wrap some tokens from the admin to the make the m vault's balance non-zero + await $.wrap($.admin, initialWrappedAmount); + + // Warp ahead slightly to change the timestamp of the new index + $.warp(new BN(60), true); + + // Propagate the start index + await $.propagateIndex(startIndex); + + // Claim yield for the m vault and complete the claim cycle + // so that the m vault is collateralized to start + await $.mClaimFor( + $.getMVault(), + await $.getTokenBalance(vaultMTokenAccount) + ); + await $.mCompleteClaims(); + + // Reset the blockhash to avoid issues with duplicate transactions from multiple claim cycles + $.svm.expireBlockhash(); + }); + + // test cases + // [X] given m earner account does not match the derived PDA + // [X] it reverts with an InvalidAccount error + // [X] given the m vault account does not match the derived PDA + // [X] it reverts with a ConstraintSeeds error + // [X] given the vault m token account is not the M vault PDA's ATA + // [X] it reverts with a ConstraintAssociated error + // [X] given the ext mint account does not match the one stored in the global account + // [X] it reverts with an InvalidMint error + // [X] given the ext mint authority account does match the derived PDA + // [X] it reverts with a ConstraintSeeds error + // [X] given the multiplier is already up to date + // [X] it remains the same + // [X] given the multiplier is not up to date + // [X] given the m vault has not received yield to match the latest M index + // [X] it reverts with an InsufficientCollateral error + // [X] given the m vault has received yield to match the latest M index + // [X] it updates the scaled ui config on the ext mint to match the m index + + // given m earner account does not match the derived PDA + // it reverts with an ConstraintSeeds / AccountNotInitialized error + test("M earner account does not match derived account - reverts", async () => { + // Change the m earner account + const mEarnerAccount = PublicKey.unique(); + if ( + mEarnerAccount.equals( + $.getMEarnerAccount( + await $.getATA($.mMint.publicKey, $.getMVault()) + ) + ) + ) { + return; + } + + // Attempt to send the transaction + // Expect an invalid account error (though could be others like not initialized) + await $.expectSystemError( + $.ext.methods + .sync() + .accountsPartial({ + mEarnerAccount, + }) + .signers([]) + .rpc() + ); + }); + + // given the m vault account does not match the derived PDA + // it reverts with a ConstraintSeeds error + test("M vault account does not match derived PDA - reverts", async () => { + // Change the m vault account + const mVault = PublicKey.unique(); + if (mVault.equals($.getMVault())) { + return; + } + + // Attempt to send the transaction + // Expect an invalid account error + await $.expectSystemError( + $.ext.methods + .sync() + .accountsPartial({ + mVault, + }) + .signers([]) + .rpc() + ); + }); + + // given the vault m token account is not the M vault PDA's ATA + // it reverts with a ConstraintAssociated error + test("M vault token account is not the M Vault PDA's ATA - reverts", async () => { + // Create a valid token account that is not the ATA + const mVault = $.getMVault(); + const { tokenAccount: vaultMTokenAccount } = + await $.createTokenAccount($.mMint.publicKey, mVault, true, true); + + // Remove the existing M earner account and create a new one for this token account + await $.removeMEarner(mVault); + const mEarnerAccount = await $.addMEarner( + mVault, + vaultMTokenAccount + ); + + // Attempt to send the transaction + // Expect revert with a ConstraintAssociated error + await $.expectAnchorError( + $.ext.methods + .sync() + .accountsPartial({ + vaultMTokenAccount, + mEarnerAccount, + }) + .signers([]) + .rpc(), + "ConstraintAssociated" + ); + }); + + // given the ext mint account does not match the one stored in the global account + // it reverts with an InvalidMint error + test("Ext mint account does not match global account - reverts", async () => { + // Create a new mint + const newMint = Keypair.generate(); + await $.createMint(newMint, $.nonAdmin.publicKey, true, 6); + + // Attempt to send the transaction + // Expect an invalid account error + await $.expectAnchorError( + $.ext.methods + .sync() + .accountsPartial({ + extMint: newMint.publicKey, + }) + .signers([]) + .rpc(), + "InvalidMint" + ); + }); + + // given the ext mint authority account does match the derived PDA + // it reverts with a ConstraintSeeds error + test("Ext mint authority account does not match derived PDA - reverts", async () => { + // Change the ext mint authority account + const extMintAuthority = PublicKey.unique(); + if (extMintAuthority.equals($.getExtMintAuthority())) { + return; + } + + // Attempt to send the transaction + // Expect an invalid account error + await $.expectAnchorError( + $.ext.methods + .sync() + .accountsPartial({ + extMintAuthority, + }) + .signers([]) + .rpc(), + "ConstraintSeeds" + ); + }); + + // given the multiplier is already up to date + // it remains the same + // the extension is solvent + test("Multiplier is already up to date - success", async () => { + // Sync the multiplier to the start index + await $.sync(); + + // Load the scaled ui config + const scaledUiAmountConfig = await $.getScaledUiAmountConfig( + $.extMint.publicKey + ); + + $.svm.expireBlockhash(); + + // Sync again + await $.sync(); + + // Confirm the scaled ui config on the ext mint is the same + $.expectScaledUiAmountConfig( + $.extMint.publicKey, + scaledUiAmountConfig + ); + + await $.expectExtSolvent(); + }); + + // given the m vault has received yield to match the latest M index + // it updates the scaled ui config on the ext mint to match the m index + // the extension is solvent + test("M vault has had yield claimed for the latest M index - success", async () => { + // Cache the scaled ui amount config + const scaledUiAmountConfig = await $.getScaledUiAmountConfig( + $.extMint.publicKey + ); + + // Send the instruction + await $.ext.methods.sync().accounts({}).signers([]).rpc(); + + // Confirm the scaled ui config on the ext mint matches the m index + const multiplier = await $.getCurrentMultiplier(); + + await $.expectScaledUiAmountConfig($.extMint.publicKey, { + authority: scaledUiAmountConfig.authority, + multiplier, + newMultiplier: multiplier, + newMultiplierEffectiveTimestamp: BigInt( + $.currentTime().toString() + ), + }); + + await $.expectExtSolvent(); + }); + }); + }); + } + }); +} diff --git a/tests/unit/scaled_ui_ext.test.ts b/tests/unit/scaled_ui_ext.test.ts deleted file mode 100644 index bf8b82f..0000000 --- a/tests/unit/scaled_ui_ext.test.ts +++ /dev/null @@ -1,3773 +0,0 @@ -import { Program, AnchorError, BN } from "@coral-xyz/anchor"; -import { LiteSVM } from "litesvm"; -import { fromWorkspace, LiteSVMProvider } from "anchor-litesvm"; -import { - PublicKey, - Keypair, - LAMPORTS_PER_SOL, - SystemProgram, - Transaction, - TransactionInstruction, -} from "@solana/web3.js"; -import { - ACCOUNT_SIZE, - ASSOCIATED_TOKEN_PROGRAM_ID, - TOKEN_PROGRAM_ID, - TOKEN_2022_PROGRAM_ID, - approveChecked, - createInitializeMintInstruction, - createAssociatedTokenAccountInstruction, - createCloseAccountInstruction, - getAccount, - getAccountLen, - getMint, - getMintLen, - getMinimumBalanceForRentExemptMultisig, - getAssociatedTokenAddressSync, - createInitializeAccountInstruction, - createInitializeMultisigInstruction, - createMintToCheckedInstruction, - ExtensionType, - getExtensionData, - createInitializeImmutableOwnerInstruction, - createApproveCheckedInstruction, -} from "@solana/spl-token"; -import { struct, u8, f64 } from "@solana/buffer-layout"; -import { publicKey, u64 } from "@solana/buffer-layout-utils"; -import { randomInt } from "crypto"; - -import { ScaledUiExt } from "../../target/types/scaled_ui_ext"; -const SCALED_UI_EXT_IDL = require("../../target/idl/scaled_ui_ext.json"); - -import { - Earn, - EARN_IDL, - PROGRAM_ID as EARN_PROGRAM_ID, - MerkleTree, - ProofElement, -} from "@m0-foundation/solana-m-sdk"; - -// Unit tests for ext earn program - -const ZERO_WORD = new Array(32).fill(0); - -// Setup wallets once at the beginning of the test suite -const admin: Keypair = new Keypair(); -const portal: Keypair = admin; // make the same since admin is allowed to push indices -const mMint: Keypair = new Keypair(); -const extMint: Keypair = new Keypair(); -const earnAuthority: Keypair = new Keypair(); -const mMintAuthority: Keypair = new Keypair(); -const wrapAuthority: Keypair = new Keypair(); -const nonAdmin: Keypair = new Keypair(); - -// Create random addresses for testing -const nonWrapAuthority: Keypair = new Keypair(); -const earnerTwo: Keypair = new Keypair(); - -let svm: LiteSVM; -let provider: LiteSVMProvider; -let accounts: Record = {}; -let earn: Program; -let scaledUiExt: Program; - -// Start parameters for M Earn -const initialSupply = new BN(100_000_000); // 100 tokens with 6 decimals -const initialIndex = new BN(1_100_000_000_000); // 1.1 -const claimCooldown = new BN(0); // None - -// Token Helper functions -const expectTokenBalance = async ( - tokenAccount: PublicKey, - expectedBalance: BN -) => { - const balance = ( - await getAccount( - provider.connection, - tokenAccount, - undefined, - TOKEN_2022_PROGRAM_ID - ) - ).amount; - - expect(balance.toString()).toEqual(expectedBalance.toString()); -}; - -enum Comparison { - Equal, - GreaterThan, - GreaterThanOrEqual, - LessThan, - LessThanOrEqual, -} - -const expectTokenUiBalance = async ( - tokenAccount: PublicKey, - expectedBalance: BN, - op: Comparison = Comparison.Equal, - tolerance?: BN -) => { - const rawBalance = ( - await getAccount( - provider.connection, - tokenAccount, - undefined, - TOKEN_2022_PROGRAM_ID - ) - ).amount; - - const multiplier = (await getScaledUiAmountConfig(extMint.publicKey)) - .multiplier; - - const scale = 1e12; - - const uiBalance = - (rawBalance * BigInt(Math.floor(multiplier * scale))) / BigInt(scale); - - switch (op) { - case Comparison.GreaterThan: - expect(uiBalance).toBeGreaterThan(BigInt(expectedBalance.toString())); - if (tolerance) { - expect(uiBalance).toBeLessThanOrEqual( - BigInt(expectedBalance.add(tolerance).toString()) - ); - } - case Comparison.GreaterThanOrEqual: - expect(uiBalance).toBeGreaterThanOrEqual( - BigInt(expectedBalance.toString()) - ); - if (tolerance) { - expect(uiBalance).toBeLessThanOrEqual( - BigInt(expectedBalance.add(tolerance).toString()) - ); - } - case Comparison.LessThan: - expect(uiBalance).toBeLessThan(BigInt(expectedBalance.toString())); - if (tolerance) { - expect(uiBalance).toBeGreaterThanOrEqual( - BigInt(expectedBalance.sub(tolerance).toString()) - ); - } - case Comparison.LessThanOrEqual: - expect(uiBalance).toBeLessThanOrEqual(BigInt(expectedBalance.toString())); - if (tolerance) { - expect(uiBalance).toBeGreaterThanOrEqual( - BigInt(expectedBalance.sub(tolerance).toString()) - ); - } - default: - if (tolerance) { - expect(uiBalance).toBeGreaterThanOrEqual( - BigInt(expectedBalance.sub(tolerance).toString()) - ); - expect(uiBalance).toBeLessThanOrEqual( - BigInt(expectedBalance.add(tolerance).toString()) - ); - } else { - expect(uiBalance).toEqual(BigInt(expectedBalance.toString())); - } - break; - } -}; - -const createATA = async ( - mint: PublicKey, - owner: PublicKey, - use2022: boolean = true -) => { - const tokenAccount = getAssociatedTokenAddressSync( - mint, - owner, - true, - use2022 ? TOKEN_2022_PROGRAM_ID : TOKEN_PROGRAM_ID, - ASSOCIATED_TOKEN_PROGRAM_ID - ); - - const createATA = createAssociatedTokenAccountInstruction( - admin.publicKey, // payer - tokenAccount, // ata - owner, // owner - mint, // mint - use2022 ? TOKEN_2022_PROGRAM_ID : TOKEN_PROGRAM_ID, - ASSOCIATED_TOKEN_PROGRAM_ID - ); - - let tx = new Transaction().add(createATA); - - await provider.sendAndConfirm!(tx, [admin]); - - return tokenAccount; -}; - -const getATA = async ( - mint: PublicKey, - owner: PublicKey, - use2022: boolean = true -) => { - // Check to see if the ATA already exists, if so return its key - const tokenAccount = getAssociatedTokenAddressSync( - mint, - owner, - true, - use2022 ? TOKEN_2022_PROGRAM_ID : TOKEN_PROGRAM_ID, - ASSOCIATED_TOKEN_PROGRAM_ID - ); - - const tokenAccountInfo = svm.getAccount(tokenAccount); - - if (!tokenAccountInfo) { - await createATA(mint, owner, use2022); - } - - return tokenAccount; -}; - -const createTokenAccount = async ( - mint: PublicKey, - owner: PublicKey, - use2022: boolean = true -) => { - // We want to create a token account that is not the ATA - const tokenAccount = new Keypair(); - - let tx = new Transaction(); - tx.add( - SystemProgram.createAccount({ - fromPubkey: admin.publicKey, - newAccountPubkey: tokenAccount.publicKey, - space: ACCOUNT_SIZE, - lamports: await provider.connection.getMinimumBalanceForRentExemption( - ACCOUNT_SIZE - ), - programId: use2022 ? TOKEN_2022_PROGRAM_ID : TOKEN_PROGRAM_ID, - }), - createInitializeAccountInstruction( - tokenAccount.publicKey, - mint, - owner, - TOKEN_2022_PROGRAM_ID - ) - ); - - await provider.sendAndConfirm!(tx, [admin, tokenAccount]); - - return { tokenAccount: tokenAccount.publicKey }; -}; - -const closeTokenAccount = async (owner: Keypair, tokenAccount: PublicKey) => { - const closeIx = createCloseAccountInstruction( - tokenAccount, - owner.publicKey, - owner.publicKey, - [], - TOKEN_2022_PROGRAM_ID - ); - - let tx = new Transaction().add(closeIx); - - await provider.sendAndConfirm!(tx, [owner]); -}; - -const createMint = async ( - mint: Keypair, - mintAuthority: PublicKey, - use2022: boolean = true, - decimals = 6 -) => { - // Create and initialize mint account - - const tokenProgram = use2022 ? TOKEN_2022_PROGRAM_ID : TOKEN_PROGRAM_ID; - - const mintLen = getMintLen([]); - const mintLamports = - await provider.connection.getMinimumBalanceForRentExemption(mintLen); - const createMintAccount = SystemProgram.createAccount({ - fromPubkey: admin.publicKey, - newAccountPubkey: mint.publicKey, - space: mintLen, - lamports: mintLamports, - programId: tokenProgram, - }); - - const initializeMint = createInitializeMintInstruction( - mint.publicKey, - decimals, // decimals - mintAuthority, // mint authority - mintAuthority, // freeze authority - tokenProgram - ); - - let tx = new Transaction(); - tx.add(createMintAccount, initializeMint); - - await provider.sendAndConfirm!(tx, [admin, mint]); - - // Verify the mint was created properly - const mintInfo = await provider.connection.getAccountInfo(mint.publicKey); - if (!mintInfo) { - throw new Error("Mint account was not created"); - } - - return mint.publicKey; -}; - -// Scaled UI Amount Config Extension Types and Functions since not supported in spl-token library yet -interface InitializeScaledUiAmountConfigData { - instruction: 43; - scaledUiAmountInstruction: 0; - authority: PublicKey | null; - multiplier: number; -} - -const initializeScaledUiAmountConfigInstructionData = - struct([ - u8("instruction"), - u8("scaledUiAmountInstruction"), - publicKey("authority"), - f64("multiplier"), - ]); - -const createInitializeScaledUiAmountConfigInstruction = ( - mint: PublicKey, - authority: PublicKey | null, - multiplier: number, - programId: PublicKey = TOKEN_2022_PROGRAM_ID -): TransactionInstruction => { - const keys = [{ pubkey: mint, isSigner: false, isWritable: true }]; - - const data = Buffer.alloc(initializeScaledUiAmountConfigInstructionData.span); - initializeScaledUiAmountConfigInstructionData.encode( - { - instruction: 43, // scaled ui amount extension - scaledUiAmountInstruction: 0, // initialize - authority: authority ?? PublicKey.default, - multiplier: multiplier, - }, - data - ); - - return new TransactionInstruction({ keys, programId, data }); -}; - -const createScaledUiMint = async ( - mint: Keypair, - mintAuthority: PublicKey, - decimals = 6 -) => { - // Create and initialize mint account - - const tokenProgram = TOKEN_2022_PROGRAM_ID; - - const mintLen = getMintLen([ExtensionType.ScaledUiAmountConfig]); - const mintLamports = - await provider.connection.getMinimumBalanceForRentExemption(mintLen); - const createMintAccount = SystemProgram.createAccount({ - fromPubkey: admin.publicKey, - newAccountPubkey: mint.publicKey, - space: mintLen, - lamports: mintLamports, - programId: tokenProgram, - }); - - const initializeScaledUiAmountConfig = - createInitializeScaledUiAmountConfigInstruction( - mint.publicKey, - mintAuthority, - 1.0, - tokenProgram - ); - - const initializeMint = createInitializeMintInstruction( - mint.publicKey, - decimals, // decimals - mintAuthority, // mint authority - mintAuthority, // freeze authority - tokenProgram - ); - - let tx = new Transaction(); - tx.add(createMintAccount, initializeScaledUiAmountConfig, initializeMint); - - await provider.sendAndConfirm!(tx, [admin, mint]); - - // Verify the mint was created properly - const mintInfo = await provider.connection.getAccountInfo(mint.publicKey); - if (!mintInfo) { - throw new Error("Mint account was not created"); - } - - return mint.publicKey; -}; - -interface ScaledUiAmountConfig { - authority: PublicKey; - multiplier: number; - newMultiplierEffectiveTimestamp: bigint; - newMultiplier: number; -} - -const ScaledUiAmountConfigLayout = struct([ - publicKey("authority"), - f64("multiplier"), - u64("newMultiplierEffectiveTimestamp"), - f64("newMultiplier"), -]); - -const getScaledUiAmountConfig = async ( - mint: PublicKey -): Promise => { - const mintAccount = await getMint( - provider.connection, - mint, - undefined, - TOKEN_2022_PROGRAM_ID - ); - const extensionData = getExtensionData( - ExtensionType.ScaledUiAmountConfig, - mintAccount.tlvData - ); - if (extensionData === null) { - throw new Error("Extension data not found"); - } - - return ScaledUiAmountConfigLayout.decode(extensionData); -}; - -const createMintWithMultisig = async ( - mint: Keypair, - mintAuthority: Keypair -) => { - // Create and initialize multisig mint authority on the token program - const multisigLen = 355; - // const multisigLamports = await provider.connection.getMinimumBalanceForRentExemption(multisigLen); - const multisigLamports = await getMinimumBalanceForRentExemptMultisig( - provider.connection - ); - - const createMultisigAccount = SystemProgram.createAccount({ - fromPubkey: admin.publicKey, - newAccountPubkey: mintAuthority.publicKey, - space: multisigLen, - lamports: multisigLamports, - programId: TOKEN_2022_PROGRAM_ID, - }); - - const earnTokenAuthority = getEarnTokenAuthority(); - - const initializeMultisig = createInitializeMultisigInstruction( - mintAuthority.publicKey, // account - [portal, earnTokenAuthority], - 1, - TOKEN_2022_PROGRAM_ID - ); - - let tx = new Transaction(); - tx.add(createMultisigAccount, initializeMultisig); - - await provider.sendAndConfirm!(tx, [admin, mintAuthority]); - - // Create and initialize mint account - - const mintLen = getMintLen([]); - const mintLamports = - await provider.connection.getMinimumBalanceForRentExemption(mintLen); - const createMintWithMultisigAccount = SystemProgram.createAccount({ - fromPubkey: admin.publicKey, - newAccountPubkey: mint.publicKey, - space: mintLen, - lamports: mintLamports, - programId: TOKEN_2022_PROGRAM_ID, - }); - - const initializeMint = createInitializeMintInstruction( - mint.publicKey, - 6, // decimals - mintAuthority.publicKey, // mint authority - null, // freeze authority - TOKEN_2022_PROGRAM_ID - ); - - tx = new Transaction(); - tx.add(createMintWithMultisigAccount, initializeMint); - - await provider.sendAndConfirm!(tx, [admin, mint]); - - // Verify the mint was created properly - const mintInfo = await provider.connection.getAccountInfo(mint.publicKey); - if (!mintInfo) { - throw new Error("Mint account was not created"); - } - - return mint.publicKey; -}; - -const mintM = async (to: PublicKey, amount: BN) => { - const toATA: PublicKey = await getATA(mMint.publicKey, to); - - const mintToInstruction = createMintToCheckedInstruction( - mMint.publicKey, - toATA, - mMintAuthority.publicKey, - BigInt(amount.toString()), - 6, - [portal], - TOKEN_2022_PROGRAM_ID - ); - - let tx = new Transaction(); - tx.add(mintToInstruction); - await provider.sendAndConfirm!(tx, [portal]); -}; - -const getTokenBalance = async (tokenAccount: PublicKey) => { - const tokenAccountInfo = await getAccount( - provider.connection, - tokenAccount, - undefined, - TOKEN_2022_PROGRAM_ID - ); - if (!tokenAccountInfo) { - throw new Error("Account not created"); - } - - return new BN(tokenAccountInfo.amount.toString()); -}; - -const getTokenUiBalance = async ( - tokenAccount: PublicKey, - multiplier?: number -) => { - const tokenAccountInfo = await getAccount( - provider.connection, - tokenAccount, - undefined, - TOKEN_2022_PROGRAM_ID - ); - - if (!tokenAccountInfo) { - throw new Error("Account not created"); - } - - const mp = - multiplier ?? - (await getScaledUiAmountConfig(tokenAccountInfo.mint)).multiplier; - - const scale = 1e12; - - const uiBalance = - (tokenAccountInfo.amount * BigInt(Math.floor(mp * scale))) / BigInt(scale); - - return new BN(uiBalance.toString()); -}; - -const approve = async ( - source: Keypair, - delegate: PublicKey, - mint: PublicKey, - amount: BN -) => { - const sourceATA: PublicKey = await getATA(mint, source.publicKey); - - const approveIx = createApproveCheckedInstruction( - sourceATA, - mint, - delegate, - source.publicKey, - BigInt(amount.toString()), - 6, // decimals - [], - TOKEN_2022_PROGRAM_ID - ); - - let tx = new Transaction(); - tx.add(approveIx); - await provider.sendAndConfirm!(tx, [source]); - - return { sourceATA }; -}; - -const warp = (seconds: BN, increment: boolean) => { - const clock = svm.getClock(); - clock.unixTimestamp = increment - ? clock.unixTimestamp + BigInt(seconds.toString()) - : BigInt(seconds.toString()); - svm.setClock(clock); -}; - -// Type definitions for accounts to make it easier to do comparisons - -interface ExtGlobal { - admin?: PublicKey; - extMint?: PublicKey; - mMint?: PublicKey; - mEarnGlobalAccount?: PublicKey; - feeBps?: BN; - lastMIndex?: BN; - lastExtIndex?: BN; - bump?: number; - mVaultBump?: number; - extMintAuthorityBump?: number; - wrapAuthorities?: PublicKey[]; -} - -const getEarnGlobalAccount = () => { - const [globalAccount] = PublicKey.findProgramAddressSync( - [Buffer.from("global")], - earn.programId - ); - - return globalAccount; -}; - -const getEarnTokenAuthority = () => { - const [earnTokenAuthority] = PublicKey.findProgramAddressSync( - [Buffer.from("token_authority")], - earn.programId - ); - - return earnTokenAuthority; -}; - -const getExtGlobalAccount = () => { - const [globalAccount] = PublicKey.findProgramAddressSync( - [Buffer.from("global")], - scaledUiExt.programId - ); - - return globalAccount; -}; - -const getExtMintAuthority = () => { - const [extMintAuthority] = PublicKey.findProgramAddressSync( - [Buffer.from("mint_authority")], - scaledUiExt.programId - ); - - return extMintAuthority; -}; - -const getMVault = () => { - const [mVault] = PublicKey.findProgramAddressSync( - [Buffer.from("m_vault")], - scaledUiExt.programId - ); - - return mVault; -}; - -const getMEarnerAccount = (tokenAccount: PublicKey) => { - const [earnerAccount] = PublicKey.findProgramAddressSync( - [Buffer.from("earner"), tokenAccount.toBuffer()], - earn.programId - ); - - return earnerAccount; -}; - -// Utility functions for the tests -const expectAccountEmpty = (account: PublicKey) => { - const accountInfo = svm.getAccount(account); - - if (accountInfo) { - expect(accountInfo.lamports).toBe(0); - expect(accountInfo.data.length).toBe(0); - expect(accountInfo.owner).toStrictEqual(SystemProgram.programId); - } -}; - -const expectAnchorError = async ( - txResult: Promise, - errCode: string -) => { - try { - await txResult; - throw new Error("Transaction should have reverted"); - } catch (e) { - if (!(e instanceof AnchorError)) - throw new Error(`Expected AnchorError, got ${e}`); - const err: AnchorError = e; - expect(err.error.errorCode.code).toStrictEqual(errCode); - } -}; - -const expectSystemError = async (txResult: Promise) => { - let reverted = false; - try { - await txResult; - } catch (e) { - // console.log(e.transactionMessage); - // console.log(e.logs); - reverted = true; - } finally { - expect(reverted).toBe(true); - } -}; - -const expectExtGlobalState = async ( - globalAccount: PublicKey, - expected: ExtGlobal -) => { - const state = await scaledUiExt.account.extGlobal.fetch(globalAccount); - - if (expected.admin) expect(state.admin).toEqual(expected.admin); - if (expected.extMint) expect(state.extMint).toEqual(expected.extMint); - if (expected.mMint) expect(state.mMint).toEqual(expected.mMint); - if (expected.mEarnGlobalAccount) - expect(state.mEarnGlobalAccount).toEqual(expected.mEarnGlobalAccount); - if (expected.feeBps) - expect(state.feeBps.toString()).toEqual(expected.feeBps.toString()); - if (expected.lastMIndex) - expect(state.lastMIndex.toString()).toEqual(expected.lastMIndex.toString()); - if (expected.lastExtIndex) - expect(state.lastExtIndex.toString()).toEqual( - expected.lastExtIndex.toString() - ); - if (expected.bump) expect(state.bump).toEqual(expected.bump); - if (expected.mVaultBump) - expect(state.mVaultBump).toEqual(expected.mVaultBump); - if (expected.extMintAuthorityBump) - expect(state.extMintAuthorityBump).toEqual(expected.extMintAuthorityBump); -}; - -const expectScaledUiAmountConfig = async ( - mint: PublicKey, - expected: ScaledUiAmountConfig -) => { - const state = await getScaledUiAmountConfig(mint); - - if (expected.authority) expect(state.authority).toEqual(expected.authority); - if (expected.multiplier) - expect(state.multiplier.toFixed(12)).toEqual( - (Math.floor(expected.multiplier * 1e12) / 1e12).toFixed(12) - ); - if (expected.newMultiplierEffectiveTimestamp) - expect(state.newMultiplierEffectiveTimestamp.toString()).toEqual( - expected.newMultiplierEffectiveTimestamp.toString() - ); - if (expected.newMultiplier) - expect(state.newMultiplier.toFixed(12)).toEqual( - (Math.floor(expected.newMultiplier * 1e12) / 1e12).toFixed(12) - ); -}; - -const createUniqueKeyArray = (size: number) => { - return new Array(size).fill(PublicKey.default).map((_, i, arr) => { - let key = PublicKey.unique(); - while (key.equals(PublicKey.default) || arr.includes(key)) { - key = PublicKey.unique(); - } - return key; - }); -}; - -const padKeyArray = (array: PublicKey[], desiredLen: number) => { - const currentLen = array.length; - - if (currentLen > desiredLen) { - throw new Error("Array is too long"); - } - - const padding = new Array(desiredLen - currentLen).fill(PublicKey.default); - return array.concat(padding); -}; - -// instruction convenience functions for earn program -const prepEarnInitialize = (signer: Keypair, mint: PublicKey) => { - // Get the global PDA - const globalAccount = getEarnGlobalAccount(); - - // Populate accounts for the instruction - accounts = {}; - accounts.admin = signer.publicKey; - accounts.globalAccount = globalAccount; - accounts.mint = mint; - accounts.systemProgram = SystemProgram.programId; - - return { globalAccount }; -}; - -const initializeEarn = async ( - mint: PublicKey, - earnAuthority: PublicKey, - initialIndex: BN, - claimCooldown: BN -) => { - // Setup the instruction - const { globalAccount } = prepEarnInitialize(admin, mint); - - // Send the transaction - try { - await earn.methods - .initialize(earnAuthority, initialIndex, claimCooldown) - .accountsPartial({ ...accounts }) - .signers([admin]) - .rpc(); - } catch (e) { - console.log(e); - throw e; - } - - return globalAccount; -}; - -const prepPropagateIndex = (signer: Keypair) => { - // Get the global PDA - const globalAccount = getEarnGlobalAccount(); - - // Populate accounts - accounts = {}; - accounts.signer = signer.publicKey; - accounts.globalAccount = globalAccount; - accounts.mint = mMint.publicKey; - - return { globalAccount }; -}; - -const propagateIndex = async ( - newIndex: BN, - earnerMerkleRoot: number[] = ZERO_WORD -) => { - // Setup the instruction - const { globalAccount } = prepPropagateIndex(portal); - - // Send the instruction - await earn.methods - .propagateIndex(newIndex, earnerMerkleRoot) - .accountsPartial({ ...accounts }) - .signers([portal]) - .rpc(); - - // We don't check state here because it depends on the circumstances - - return { globalAccount }; -}; - -const prepMClaimFor = async ( - signer: Keypair, - mint: PublicKey, - earner: PublicKey -) => { - // Get the global and token authority PDAs - const globalAccount = getEarnGlobalAccount(); - const earnTokenAuthority = getEarnTokenAuthority(); - - // Get the earner ATA - const earnerATA = await getATA(mint, earner); - - // Get the earner account - const earnerAccount = getMEarnerAccount(earnerATA); - - // Populate accounts - accounts = {}; - accounts.earnAuthority = signer.publicKey; - accounts.globalAccount = globalAccount; - accounts.earnerAccount = earnerAccount; - accounts.mint = mint; - accounts.mintMultisig = mMintAuthority.publicKey; - accounts.tokenAuthorityAccount = earnTokenAuthority; - accounts.userTokenAccount = earnerATA; - accounts.tokenProgram = TOKEN_2022_PROGRAM_ID; - - return { globalAccount, earnerAccount, earnerATA }; -}; - -const mClaimFor = async (earner: PublicKey, balance?: BN) => { - // Setup the instruction - const { globalAccount, earnerAccount, earnerATA } = await prepMClaimFor( - earnAuthority, - mMint.publicKey, - earner - ); - - const snapshotBalance = balance ?? (await getTokenBalance(earnerATA)); - - // Send the instruction - await earn.methods - .claimFor(snapshotBalance) - .accountsPartial({ ...accounts }) - .signers([earnAuthority]) - .rpc(); - - return { globalAccount, earnerAccount, earnerATA }; -}; - -const prepCompleteClaims = (signer: Keypair) => { - // Get the global PDA - const globalAccount = getEarnGlobalAccount(); - - // Populate accounts - accounts = {}; - accounts.earnAuthority = signer.publicKey; - accounts.globalAccount = globalAccount; - - return { globalAccount }; -}; - -const completeClaims = async () => { - // Setup the instruction - prepCompleteClaims(earnAuthority); - - // Send the instruction - await earn.methods - .completeClaims() - .accountsPartial({ ...accounts }) - .signers([earnAuthority]) - .rpc(); -}; - -const prepAddRegistrarEarner = (signer: Keypair, earnerATA: PublicKey) => { - // Get the global PDA - const globalAccount = getEarnGlobalAccount(); - - // Get the earner account - const earnerAccount = getMEarnerAccount(earnerATA); - - // Populate accounts - accounts = {}; - accounts.signer = signer.publicKey; - accounts.userTokenAccount = earnerATA; - accounts.globalAccount = globalAccount; - accounts.earnerAccount = earnerAccount; - accounts.systemProgram = SystemProgram.programId; - - return { globalAccount, earnerAccount }; -}; - -const addRegistrarEarner = async (earner: PublicKey, proof: ProofElement[]) => { - // Get the earner ATA - const earnerATA = await getATA(mMint.publicKey, earner); - - // Setup the instruction - prepAddRegistrarEarner(nonAdmin, earnerATA); - - // Send the instruction - await earn.methods - .addRegistrarEarner(earner, proof) - .accountsPartial({ ...accounts }) - .signers([nonAdmin]) - .rpc(); -}; - -// Helper functions for preparing and executing ScaledUiExt instructions - -const prepExtInitialize = async (signer: Keypair) => { - // Get the global PDA - const globalAccount = getExtGlobalAccount(); - - // Populate accounts for the instruction - accounts = {}; - accounts.admin = signer.publicKey; - accounts.globalAccount = globalAccount; - accounts.mMint = mMint.publicKey; - accounts.extMint = extMint.publicKey; - accounts.extMintAuthority = getExtMintAuthority(); - accounts.mEarnGlobalAccount = getEarnGlobalAccount(); - accounts.mVault = getMVault(); - accounts.vaultMTokenAccount = await getATA(mMint.publicKey, accounts.mVault); - accounts.systemProgram = SystemProgram.programId; - accounts.mTokenProgram = TOKEN_2022_PROGRAM_ID; - accounts.extTokenProgramn = TOKEN_2022_PROGRAM_ID; - - return { globalAccount }; -}; - -const initializeExt = async (wrapAuthorities: PublicKey[], fee_bps: BN) => { - // Setup the instruction - const { globalAccount } = await prepExtInitialize(admin); - - // Send the transaction - await scaledUiExt.methods - .initialize(wrapAuthorities, fee_bps) - .accountsPartial({ ...accounts }) - .signers([admin]) - .rpc(); - - return globalAccount; -}; - -const prepSetMMint = async ( - signer: Keypair, - mint: PublicKey, - newVaultMTokenAccount?: PublicKey -) => { - // Get the global PDA - const globalAccount = getExtGlobalAccount(); - - // Populate accounts for the instruction - accounts = {}; - accounts.admin = signer.publicKey; - accounts.globalAccount = globalAccount; - accounts.mVault = getMVault(); - accounts.mMint = mMint.publicKey; - accounts.vaultMTokenAccount = await getATA(mMint.publicKey, accounts.mVault); - accounts.newMMint = mint; - accounts.newVaultMTokenAccount = - newVaultMTokenAccount ?? (await getATA(mint, accounts.mVault)); - - return { globalAccount }; -}; - -const setMMint = async (mint: PublicKey) => { - // Setup the instruction - const { globalAccount } = await prepSetMMint(admin, mint); - - // Send the instruction - await scaledUiExt.methods - .setMMint() - .accountsPartial({ ...accounts }) - .signers([admin]) - .rpc(); - - return globalAccount; -}; - -const prepUpdateWrapAuthority = (signer: Keypair) => { - // Get the global PDA - const globalAccount = getExtGlobalAccount(); - - // Populate accounts for the instruction - accounts = {}; - accounts.admin = signer.publicKey; - accounts.globalAccount = globalAccount; - - return { globalAccount }; -}; - -const updateWrapAuthority = async ( - index: number, - newWrapAuthority: PublicKey -) => { - // Setup the instruction - const { globalAccount } = prepUpdateWrapAuthority(admin); - - // Send the instruction - await scaledUiExt.methods - .updateWrapAuthority(index, newWrapAuthority) - .accountsPartial({ ...accounts }) - .signers([admin]) - .rpc(); - - return globalAccount; -}; - -const prepWrap = async ( - from: Keypair, - to?: PublicKey, - fromMTokenAccount?: PublicKey, - toExtTokenAccount?: PublicKey, - vaultMTokenAccount?: PublicKey -) => { - // Get the M vault pda - const mVault = getMVault(); - - // Populate accounts - accounts = {}; - accounts.signer = from.publicKey; - accounts.mMint = mMint.publicKey; - accounts.extMint = extMint.publicKey; - accounts.globalAccount = getExtGlobalAccount(); - accounts.mEarnGlobalAccount = getEarnGlobalAccount(); - accounts.mVault = mVault; - accounts.extMintAuthority = getExtMintAuthority(); - accounts.fromMTokenAccount = - fromMTokenAccount ?? (await getATA(mMint.publicKey, from.publicKey)); - accounts.toExtTokenAccount = - toExtTokenAccount ?? - (await getATA(extMint.publicKey, to ?? from.publicKey)); - accounts.vaultMTokenAccount = - vaultMTokenAccount ?? (await getATA(mMint.publicKey, mVault)); - accounts.mTokenProgram = TOKEN_2022_PROGRAM_ID; - accounts.extTokenProgram = TOKEN_2022_PROGRAM_ID; - - return { - vaultMTokenAccount: accounts.vaultMTokenAccount, - fromMTokenAccount: accounts.fromMTokenAccount, - toExtTokenAccount: accounts.toExtTokenAccount, - }; -}; - -const wrap = async (from: Keypair, amount: BN, to?: PublicKey) => { - // Setup the instruction - const { vaultMTokenAccount, fromMTokenAccount, toExtTokenAccount } = - await prepWrap(from, to); - - // Send the instruction - await scaledUiExt.methods - .wrap(amount) - .accountsPartial({ ...accounts }) - .signers([from]) - .rpc(); - - return { vaultMTokenAccount, fromMTokenAccount, toExtTokenAccount }; -}; - -const prepUnwrap = async ( - from: Keypair, - to?: PublicKey, - toMTokenAccount?: PublicKey, - fromExtTokenAccount?: PublicKey, - vaultMTokenAccount?: PublicKey -) => { - // Get m vault pda - const mVault = getMVault(); - - // Populate accounts - accounts = {}; - accounts.signer = from.publicKey; - accounts.mMint = mMint.publicKey; - accounts.extMint = extMint.publicKey; - accounts.globalAccount = getExtGlobalAccount(); - accounts.mEarnGlobalAccount = getEarnGlobalAccount(); - accounts.mVault = mVault; - accounts.extMintAuthority = getExtMintAuthority(); - accounts.toMTokenAccount = - toMTokenAccount ?? (await getATA(mMint.publicKey, to ?? from.publicKey)); - accounts.fromExtTokenAccount = - fromExtTokenAccount ?? (await getATA(extMint.publicKey, from.publicKey)); - accounts.vaultMTokenAccount = - vaultMTokenAccount ?? (await getATA(mMint.publicKey, mVault)); - accounts.mTokenProgram = TOKEN_2022_PROGRAM_ID; - accounts.extTokenProgram = TOKEN_2022_PROGRAM_ID; - - return { - vaultMTokenAccount: accounts.vaultMTokenAccount, - toMTokenAccount: accounts.toMTokenAccount, - fromExtTokenAccount: accounts.fromExtTokenAccount, - }; -}; - -const unwrap = async (from: Keypair, amount: BN, to?: PublicKey) => { - // Setup the instruction - const { vaultMTokenAccount, toMTokenAccount, fromExtTokenAccount } = - await prepUnwrap(from, to); - - // Send the instruction - await scaledUiExt.methods - .unwrap(amount) - .accountsPartial({ ...accounts }) - .signers([from]) - .rpc(); - - return { vaultMTokenAccount, toMTokenAccount, fromExtTokenAccount }; -}; - -const prepSync = async (signer: Keypair) => { - // Get the global PDA - const globalAccount = getExtGlobalAccount(); - - // Populate accounts for the instruction - accounts = {}; - accounts.signer = signer.publicKey; - accounts.globalAccount = globalAccount; - accounts.mVault = getMVault(); - accounts.vaultMTokenAccount = await getATA(mMint.publicKey, accounts.mVault); - accounts.extMint = extMint.publicKey; - accounts.extMintAuthority = getExtMintAuthority(); - accounts.extTokenProgram = TOKEN_2022_PROGRAM_ID; - - return { globalAccount }; -}; - -const sync = async () => { - // Setup the instruction - const { globalAccount } = await prepSync(admin); - - // Send the instruction - await scaledUiExt.methods - .sync() - .accountsPartial({ ...accounts }) - .signers([admin]) - .rpc(); - - return globalAccount; -}; - -const prepClaimFees = async (signer: Keypair, toTokenAccount?: PublicKey) => { - // Get the global PDA - const globalAccount = getExtGlobalAccount(); - - // Populate accounts for the instruction - accounts = {}; - accounts.admin = signer.publicKey; - accounts.globalAccount = globalAccount; - accounts.mEarnGlobalAccount = getEarnGlobalAccount(); - accounts.extMint = extMint.publicKey; - accounts.extMintAuthority = getExtMintAuthority(); - accounts.mVault = getMVault(); - accounts.vaultMTokenAccount = await getATA( - mMint.publicKey, - accounts.mVault, - true - ); - accounts.recipientExtTokenAccount = - toTokenAccount ?? (await getATA(extMint.publicKey, signer.publicKey, true)); - accounts.mTokenProgram = TOKEN_2022_PROGRAM_ID; - accounts.extTokenProgram = TOKEN_2022_PROGRAM_ID; - - return { globalAccount }; -}; - -const claimFees = async (toTokenAccount?: PublicKey) => { - // Setup the instruction - const { globalAccount } = await prepClaimFees(admin, toTokenAccount); - - // Send the instruction - await scaledUiExt.methods - .claimFees() - .accountsPartial({ ...accounts }) - .signers([admin]) - .rpc(); - - return globalAccount; -}; - -describe("ScaledUiExt unit tests", () => { - let currentTime: () => BN; - - beforeEach(async () => { - // Initialize the SVM instance with all necessary configurations - svm = fromWorkspace("") - .withSplPrograms() // Add SPL programs (including token programs) - .withBuiltins() // Add builtin programs - .withSysvars() // Setup standard sysvars - .withPrecompiles() // Add standard precompiles - .withBlockhashCheck(true); // Optional: disable blockhash checking for tests - - // Add the earn program to the SVM instance - svm.addProgramFromFile(EARN_PROGRAM_ID, "tests/programs/earn.so"); - - // Replace the default token2022 program with the (newer) one from the workspace - svm.addProgramFromFile( - TOKEN_2022_PROGRAM_ID, - "tests/programs/spl_token_2022.so" - ); - - // Create an anchor provider from the liteSVM instance - provider = new LiteSVMProvider(svm); - - // Create program instances - earn = new Program(EARN_IDL, provider); - scaledUiExt = new Program(SCALED_UI_EXT_IDL, provider); - - // Fund the wallets - svm.airdrop(admin.publicKey, BigInt(10 * LAMPORTS_PER_SOL)); - svm.airdrop(earnAuthority.publicKey, BigInt(10 * LAMPORTS_PER_SOL)); - svm.airdrop(nonAdmin.publicKey, BigInt(10 * LAMPORTS_PER_SOL)); - svm.airdrop(wrapAuthority.publicKey, BigInt(10 * LAMPORTS_PER_SOL)); - svm.airdrop(nonWrapAuthority.publicKey, BigInt(10 * LAMPORTS_PER_SOL)); - - currentTime = () => { - return new BN(svm.getClock().unixTimestamp.toString()); - }; - - // Create the M token mint - await createMintWithMultisig(mMint, mMintAuthority); - - // Create the Ext token mint - await createScaledUiMint(extMint, getExtMintAuthority()); - - // Mint some m tokens to have a non-zero supply - await mintM(admin.publicKey, initialSupply); - - // Initialize the earn program - await initializeEarn( - mMint.publicKey, - earnAuthority.publicKey, - initialIndex, - claimCooldown - ); - - // Add the m vault as an M earner - const mVault = getMVault(); - const earnerMerkleTree = new MerkleTree([admin.publicKey, mVault]); - - // Propagate the merkle root - await propagateIndex(initialIndex, earnerMerkleTree.getRoot()); - - // Add the earner account for the vault - const { proof } = earnerMerkleTree.getInclusionProof(mVault); - await addRegistrarEarner(mVault, proof); - }); - - describe("admin instruction tests", () => { - describe("initialize unit tests", () => { - // test cases - // [X] given the m_mint is not owned by the token2022 program - // [X] it reverts with a ConstraintAddress error - // [X] given the ext_mint is not owned by the token2022 program - // [X] it reverts with a ConstraintMintTokenProgram error - // [X] given the ext_mint does not have the scaled ui amount extension - // [X] it reverts with a InvalidMint error - // [X] given the ext_mint has the scaled ui amount extension, but the authority is not the mint authority PDA - // [X] it reverts with an InvalidMint error - // [X] given the ext_mint decimals do not match the m_mint decimals - // [X] it reverts with a ConstraintMintDecimals error - // [X] given the M earn global account does not match the PDA on the earn program - // [X] it reverts with a SeedsConstraint error - // [X] given the ext_mint_authority is not the required PDA - // [X] it reverts with a SeedsConstraint error - // [X] given more than 10 wrap authorities are provided - // [X] it reverts with an InvalidParam error - // [X] given wrap authorities includes the system program id (default public key) - // [X] it reverts with an InvalidParam error - // [X] given the wrap authorities are not unique - // [X] it reverts with an InvalidParam error - // [X] given all accounts and params are correct - // [X] the global account is created - // [X] the admin is set to the signer - // [X] the m_mint is set correctly - // [X] the ext_mint is set correctly - // [X] the m_earn_global_account is set correctly - // [X] the bumps are set correctly - // [X] the wrap authorities are set correctly - // [X] the multiplier on the ext mint is initialized to m index - // [X] the timestamp on the ext mint is set to the m timestamp - - // given the m_mint is not owned by the token2022 program - // it reverts with a ConstraintAddress error - test("m_mint not owned by token2022 - reverts", async () => { - // Create a mint owned by a different program - const wrongMint = new Keypair(); - await createMint(wrongMint, nonAdmin.publicKey, false); - - // Setup the instruction call - prepExtInitialize(nonAdmin); - - // Change the M mint and vault token account - accounts.mMint = wrongMint.publicKey; - accounts.vaultMTokenAccount = await getATA( - wrongMint.publicKey, - getMVault(), - false - ); - - // Attempt to send the transaction - await expectAnchorError( - scaledUiExt.methods - .initialize([], new BN(0)) - .accountsPartial({ ...accounts }) - .signers([nonAdmin]) - .rpc(), - "ConstraintAddress" - ); - }); - - // given the ext_mint is not owned by the token2022 program - // it reverts with a ConstraintMintTokenProgram error - test("ext_mint not owned by token2022 - reverts", async () => { - // Create a mint owned by a different program - const wrongMint = new Keypair(); - await createMint(wrongMint, nonAdmin.publicKey, false); - - // Setup the instruction call - prepExtInitialize(nonAdmin); - - // Change the Ext Mint - accounts.extMint = wrongMint.publicKey; - - // Attempt to send the transaction - await expectAnchorError( - scaledUiExt.methods - .initialize([], new BN(0)) - .accountsPartial({ ...accounts }) - .signers([nonAdmin]) - .rpc(), - "ConstraintMintTokenProgram" - ); - }); - - // given the ext_mint does not have the scaled ui amount extension - // it reverts with a InvalidMint error - test("ext_mint does not have the scaled ui amount extension - reverts", async () => { - // Create a mint without the scaled ui amount extension - const wrongMint = new Keypair(); - await createMint(wrongMint, getExtMintAuthority(), true, 6); // valid otherwise - - // Setup the instruction call - prepExtInitialize(nonAdmin); - - // Change the Ext Mint - accounts.extMint = wrongMint.publicKey; - - // Attempt to send the transaction - await expectAnchorError( - scaledUiExt.methods - .initialize([], new BN(0)) - .accountsPartial({ ...accounts }) - .signers([nonAdmin]) - .rpc(), - "InvalidMint" - ); - }); - - // given the ext_mint has the scaled ui amount extension, but the authority is not the mint authority PDA - // it reverts with an InvalidMint error - test("ext_mint has the scaled ui amount extension, but the authority is not the mint authority PDA - reverts", async () => { - // Create a mint with the scaled ui amount extension - const wrongMint = new Keypair(); - await createScaledUiMint(wrongMint, nonAdmin.publicKey, 6); - - // Setup the instruction call - prepExtInitialize(nonAdmin); - - // Change the Ext Mint - accounts.extMint = wrongMint.publicKey; - - // Attempt to send the transaction - await expectAnchorError( - scaledUiExt.methods - .initialize([], new BN(0)) - .accountsPartial({ ...accounts }) - .signers([nonAdmin]) - .rpc(), - "InvalidMint" - ); - }); - - // given the decimals on ext_mint do not match M - // it reverts with a MintDecimals error - test("ext_mint incorrect decimals - reverts", async () => { - // Create a mint owned by a different program - const badMint = new Keypair(); - await createMint(badMint, nonAdmin.publicKey, true, 9); - - // Setup the instruction call - prepExtInitialize(nonAdmin); - - // Change the Ext Mint - accounts.extMint = badMint.publicKey; - - // Attempt to send the transaction - await expectAnchorError( - scaledUiExt.methods - .initialize([], new BN(0)) - .accountsPartial({ ...accounts }) - .signers([nonAdmin]) - .rpc(), - "ConstraintMintDecimals" - ); - }); - - // given the M earn global account is invalid - // it reverts with a seeds constraint - test("m_earn_global_account is incorrect - reverts", async () => { - // Setup the instruction call - prepExtInitialize(nonAdmin); - - // Change the m earn global account - accounts.mEarnGlobalAccount = PublicKey.unique(); - if (accounts.mEarnGlobalAccount == getEarnGlobalAccount()) return; - - // Attempt to send transaction - // Expect error (could be one of several "SeedsConstraint", "AccountOwnedByWrongProgram", "AccountNotInitialized") - await expectSystemError( - scaledUiExt.methods - .initialize([], new BN(0)) - .accountsPartial({ ...accounts }) - .signers([nonAdmin]) - .rpc() - ); - }); - - // given ext_mint_authority is not required PDA - // it reverts with a seeds constraint - test("ext_mint_authority is incorrect - reverts", async () => { - // Setup the instruction call - prepExtInitialize(nonAdmin); - - // Change the ext mint authority - accounts.extMintAuthority = PublicKey.unique(); - if (accounts.extMintAuthority == getExtMintAuthority()) return; - - // Attempt to send transaction - // Expect error (could be one of several "SeedsConstraint", "AccountOwnedByWrongProgram", "AccountNotInitialized") - await expectSystemError( - scaledUiExt.methods - .initialize([], new BN(0)) - .accountsPartial({ ...accounts }) - .signers([nonAdmin]) - .rpc() - ); - }); - - // given more than 10 wrap authorities are provided - // it reverts with an InvalidParam error - test("more than 10 wrap authorities provided - reverts", async () => { - // Setup the instruction call - prepExtInitialize(nonAdmin); - - // Change the wrap authorities - const wrapAuthorities: PublicKey[] = createUniqueKeyArray(11); - - // Attempt to send transaction - await expectAnchorError( - scaledUiExt.methods - .initialize(wrapAuthorities, new BN(0)) - .accountsPartial({ ...accounts }) - .signers([nonAdmin]) - .rpc(), - "InvalidParam" - ); - }); - - // given wrap authorities includes the system program id (default public key) - // it reverts with an InvalidParam error - test("wrap authorities includes the system program id - reverts", async () => { - // Setup the instruction call - prepExtInitialize(nonAdmin); - - // Change the wrap authorities - const wrapAuthorities: PublicKey[] = createUniqueKeyArray(10); - wrapAuthorities[0] = SystemProgram.programId; - - // Attempt to send transaction - await expectAnchorError( - scaledUiExt.methods - .initialize(wrapAuthorities, new BN(0)) - .accountsPartial({ ...accounts }) - .signers([nonAdmin]) - .rpc(), - "InvalidParam" - ); - }); - - // given wrap authorities includes a duplicate, non-default public key - // it reverts with an InvalidParam error - test("wrap authorities includes a duplicate, non-default public key - reverts", async () => { - // Setup the instruction call - prepExtInitialize(nonAdmin); - - // Change the wrap authorities - const wrapAuthorities: PublicKey[] = createUniqueKeyArray(10); - wrapAuthorities[0] = wrapAuthorities[1]; - - // Attempt to send transaction - await expectAnchorError( - scaledUiExt.methods - .initialize(wrapAuthorities, new BN(0)) - .accountsPartial({ ...accounts }) - .signers([nonAdmin]) - .rpc(), - "InvalidParam" - ); - }); - - // given accounts and params are correct - // it creates the global account - // it sets the admin to the signer - // it sets the m_mint to the provided mint - // it sets the ext_mint to the provided mint - // it sets the m_earn_global_account to the provided account - // it sets the scalued ui amount multiplier and timestamp to the values on the m earn global account - // it sets the bumps to the correct values - test("initialize - success", async () => { - // Setup the instruction call - prepExtInitialize(admin); - - // Get a random number of wrap authorities - // We use the padded array to check the stored state after the call - const numWrapAuthorities = randomInt(10); - const wrapAuthorities: PublicKey[] = - createUniqueKeyArray(numWrapAuthorities); - const paddedWrapAuthorities = padKeyArray(wrapAuthorities, 10); - - // Derive PDA bumps - const [, bump] = PublicKey.findProgramAddressSync( - [Buffer.from("global")], - scaledUiExt.programId - ); - const [, mVaultBump] = PublicKey.findProgramAddressSync( - [Buffer.from("m_vault")], - scaledUiExt.programId - ); - const [, extMintAuthorityBump] = PublicKey.findProgramAddressSync( - [Buffer.from("mint_authority")], - scaledUiExt.programId - ); - - // Ensure the global account has not been created yet - const globalAccount = getExtGlobalAccount(); - expectAccountEmpty(globalAccount); - - // Get a random fee bps - const fee_bps = new BN(randomInt(10000)); - - // Send the transaction - await scaledUiExt.methods - .initialize(wrapAuthorities, fee_bps) - .accountsPartial({ ...accounts }) - .signers([admin]) - .rpc(); - - // Check the state of the global account - await expectExtGlobalState(globalAccount, { - admin: admin.publicKey, - extMint: extMint.publicKey, - mMint: mMint.publicKey, - mEarnGlobalAccount: getEarnGlobalAccount(), - feeBps: fee_bps, - lastMIndex: initialIndex, - lastExtIndex: new BN(1e12), - bump, - mVaultBump, - extMintAuthorityBump, - wrapAuthorities: paddedWrapAuthorities, - }); - - // Check the state of the mint - await expectScaledUiAmountConfig(extMint.publicKey, { - authority: getExtMintAuthority(), - multiplier: 1.0, - newMultiplierEffectiveTimestamp: BigInt(currentTime().toString()), - newMultiplier: 1.0, - }); - }); - }); - - describe("set_m_mint unit tests", () => { - beforeEach(async () => { - const fee_bps = new BN(randomInt(10000)); - // Initialize the extension program - await initializeExt( - [admin.publicKey, wrapAuthority.publicKey], - fee_bps - ); - - // wrap some tokens to the make the m vault's balance non-zero - await wrap(admin, initialSupply); - }); - - // 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 m mint is not owned by the token2022 program - // [X] it reverts with a ConstraintMintTokenProgram error - // [X] given the new m mint has a different number of decimals than the existing m mint - // [X] it reverts with a ConstraintMintDecimals error - // [X] given the m vault is not the m vault PDA - // [X] it reverts with a ConstraintSeeds error - // [X] given the m vault token account for the current m mint is not the m vault PDA's ATA - // [X] it reverts with a ConstraintAssociated error - // [X] given the m vault token account for the new m mint is not the m vault PDA's ATA - // [X] it reverts with a ConstraintAssociated error - // [X] given the m vault token account for the new m mint has fewer tokens than the m vault token account for the current m mint - // [X] it reverts with an InsufficientCollateral error - // [X] given all the accounts are correct - // [X] it sets the m mint to the new mint - - // given the admin does not sign the transaction - // it reverts with a NotAuthorized error - test("admin does not sign - reverts", async () => { - // Create a new m mint that is valid - const newMint = new Keypair(); - await createMint(newMint, nonAdmin.publicKey, true, 6); - - // Setup the instruction - await prepSetMMint(nonAdmin, newMint.publicKey); - - // Attempt to send the transaction - await expectAnchorError( - scaledUiExt.methods - .setMMint() - .accountsPartial({ ...accounts }) - .signers([nonAdmin]) - .rpc(), - "NotAuthorized" - ); - }); - - // given the admin signs the transaction - // given the new m mint is not owned by the token2022 program - // it reverts with a ConstraintAddress error - test("new m mint not owned by token2022 - reverts", async () => { - // Create a new m mint that is valid - const newMint = new Keypair(); - await createMint(newMint, nonAdmin.publicKey, false); - - // Setup the instruction - const newVaultATA = await getATA(newMint.publicKey, getMVault(), false); - await prepSetMMint(admin, newMint.publicKey, newVaultATA); - - // Attempt to send the transaction - await expectAnchorError( - scaledUiExt.methods - .setMMint() - .accountsPartial({ ...accounts }) - .signers([admin]) - .rpc(), - "ConstraintMintTokenProgram" - ); - }); - - // given the new m mint has a different number of decimals than the existing m mint - // it reverts with a ConstraintMintDecimals error - test("new m mint incorrect decimals - reverts", async () => { - // Create a new m mint that is valid - const newMint = new Keypair(); - await createMint(newMint, nonAdmin.publicKey, true, 9); - - // Setup the instruction - await prepSetMMint(admin, newMint.publicKey); - - // Attempt to send the transaction - await expectAnchorError( - scaledUiExt.methods - .setMMint() - .accountsPartial({ ...accounts }) - .signers([admin]) - .rpc(), - "ConstraintMintDecimals" - ); - }); - - // given the m vault is not the m vault PDA - // it reverts with a SeedsConstraint error - test("m vault is not the m vault PDA - reverts", async () => { - // Create a new m mint that is valid - const newMint = new Keypair(); - await createMint(newMint, nonAdmin.publicKey, true, 6); - - // Setup the instruction - await prepSetMMint(admin, newMint.publicKey); - - // Change the m vault - accounts.mVault = PublicKey.unique(); - - // Attempt to send the transaction - await expectAnchorError( - scaledUiExt.methods - .setMMint() - .accountsPartial({ ...accounts }) - .signers([admin]) - .rpc(), - "ConstraintSeeds" - ); - }); - - // given the m vault token account for the current m mint is not the m vault PDA's ATA - // it reverts with a ConstraintAssociated error - test("m vault token account for current m mint is not the m vault PDA's ATA - reverts", async () => { - // Create a new m mint that is valid - const newMint = new Keypair(); - await createMint(newMint, nonAdmin.publicKey, true, 6); - - // Setup the instruction - await prepSetMMint(admin, newMint.publicKey); - - // Change the m vault token account - const { tokenAccount: nonAtaAccount } = await createTokenAccount( - mMint.publicKey, - getMVault() - ); - accounts.vaultMTokenAccount = nonAtaAccount; - - // Attempt to send the transaction - await expectAnchorError( - scaledUiExt.methods - .setMMint() - .accountsPartial({ ...accounts }) - .signers([admin]) - .rpc(), - "ConstraintAssociated" - ); - }); - - // given the m vault token account for the new m mint is not the m vault PDA's ATA - // it reverts with a ConstraintAssociated error - test("m vault token account for new m mint is not the m vault PDA's ATA - reverts", async () => { - // Create a new m mint that is valid - const newMint = new Keypair(); - await createMint(newMint, nonAdmin.publicKey, true, 6); - - // Setup the instruction - await prepSetMMint(admin, newMint.publicKey); - - // Change the m vault token account - const { tokenAccount: nonAtaAccount } = await createTokenAccount( - newMint.publicKey, - getMVault() - ); - accounts.newVaultMTokenAccount = nonAtaAccount; - - // Attempt to send the transaction - await expectAnchorError( - scaledUiExt.methods - .setMMint() - .accountsPartial({ ...accounts }) - .signers([admin]) - .rpc(), - "ConstraintAssociated" - ); - }); - - // given the m vault token account for the new m mint has fewer tokens than the m vault token account for the current m mint - // it reverts with an InsufficientCollateral error - test("new m mint vault token account has fewer tokens than current m mint vault token account - reverts", async () => { - // Create a new m mint that is valid - const newMint = new Keypair(); - await createMint(newMint, nonAdmin.publicKey, true, 6); - - // Create the ATA for the m vault for the new mint and mint some tokens to it - const mVaultATA: PublicKey = await getATA( - newMint.publicKey, - getMVault() - ); - - const amount = BigInt(randomInt(initialSupply.toNumber())); - - const mintToInstruction = createMintToCheckedInstruction( - newMint.publicKey, - mVaultATA, - nonAdmin.publicKey, - amount, - 6, - [], - TOKEN_2022_PROGRAM_ID - ); - - let tx = new Transaction(); - tx.add(mintToInstruction); - await provider.sendAndConfirm!(tx, [nonAdmin]); - - // Setup the instruction - await prepSetMMint(admin, newMint.publicKey); - - // Send the transaction - // Expect an insufficient collateral error - await expectAnchorError( - scaledUiExt.methods - .setMMint() - .accountsPartial({ ...accounts }) - .signers([admin]) - .rpc(), - "InsufficientCollateral" - ); - }); - - // given all the accounts are correct - // it sets the m mint to the new mint - // Create a new m mint that is valid - test("set m mint - success", async () => { - const newMint = new Keypair(); - await createMint(newMint, nonAdmin.publicKey, true, 6); - - // Create the ATA for the m vault for the new mint and mint some tokens to it - const mVaultATA: PublicKey = await getATA( - newMint.publicKey, - getMVault() - ); - - const amount = BigInt(initialSupply.toString()); - - const mintToInstruction = createMintToCheckedInstruction( - newMint.publicKey, - mVaultATA, - nonAdmin.publicKey, - amount, - 6, - [], - TOKEN_2022_PROGRAM_ID - ); - - let tx = new Transaction(); - tx.add(mintToInstruction); - await provider.sendAndConfirm!(tx, [nonAdmin]); - - // Setup the instruction - const { globalAccount } = await prepSetMMint(admin, newMint.publicKey); - - // Send the transaction - await scaledUiExt.methods - .setMMint() - .accountsPartial({ ...accounts }) - .signers([admin]) - .rpc(); - - // Check that the m mint was updated - expectExtGlobalState(globalAccount, { - mMint: newMint.publicKey, - }); - }); - }); - - describe("update_wrap_authority unit tests", () => { - const wrapAuthorities = [admin.publicKey, wrapAuthority.publicKey]; - const paddedWrapAuthorities = padKeyArray(wrapAuthorities, 10); - - beforeEach(async () => { - const fee_bps = new BN(randomInt(10000)); - // Initialize the extension program - await initializeExt(wrapAuthorities, fee_bps); - }); - - // 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 index is out of bounds - // [X] it reverts with a InvalidParam error - // [X] given the new wrap authority is already in the list (and not the default public key) - // [X] it reverts with a InvalidParam error - // [X] given the new wrap authority is the default public key - // [X] it removes the wrap authority at the given index - // [X] given the new wrap authority is not the default public key and not in the list - // [X] it adds the new wrap authority to the list at the provided index - - // given the admin does not sign the transaction - // it reverts with a NotAuthorized error - test("admin does not sign - reverts", async () => { - // Setup the instruction - await prepUpdateWrapAuthority(nonAdmin); - - // Attempt to send the transaction - await expectAnchorError( - scaledUiExt.methods - .updateWrapAuthority(0, nonWrapAuthority.publicKey) - .accountsPartial({ ...accounts }) - .signers([nonAdmin]) - .rpc(), - "NotAuthorized" - ); - }); - - // given the admin signs the transaction - // given the index is out of bounds - // it reverts with a InvalidParam error - test("index out of bounds - reverts", async () => { - // Setup the instruction - await prepUpdateWrapAuthority(admin); - - const index = randomInt(11, 256); - - // Attempt to send the transaction - await expectAnchorError( - scaledUiExt.methods - .updateWrapAuthority(index, nonWrapAuthority.publicKey) - .accountsPartial({ ...accounts }) - .signers([admin]) - .rpc(), - "InvalidParam" - ); - }); - - // given the admin signs the transaction - // given the new wrap authority is already in the list (and not the default public key) - // it reverts with a InvalidParam error - test("new wrap authority already in the list - reverts", async () => { - // Setup the instruction - await prepUpdateWrapAuthority(admin); - - // Attempt to send the transaction - await expectAnchorError( - scaledUiExt.methods - .updateWrapAuthority(0, wrapAuthority.publicKey) - .accountsPartial({ ...accounts }) - .signers([admin]) - .rpc(), - "InvalidParam" - ); - }); - - // given the admin signs the transaction - // given the new wrap authority is the default public key - // it removes the wrap authority at the given index - test("new wrap authority is the default public key - success", async () => { - // Setup the instruction - const { globalAccount } = await prepUpdateWrapAuthority(admin); - - // Send the transaction - await scaledUiExt.methods - .updateWrapAuthority(0, SystemProgram.programId) - .accountsPartial({ ...accounts }) - .signers([admin]) - .rpc(); - - // Check that the wrap authority was removed - const updatedWrapAuthorities = paddedWrapAuthorities.slice(0); - updatedWrapAuthorities[0] = SystemProgram.programId; - - await expectExtGlobalState(globalAccount, { - wrapAuthorities: updatedWrapAuthorities, - }); - }); - - // given the admin signs the transaction - // given the new wrap authority is not the default public key and not in the list - // it adds the new wrap authority to the list at the provided index - test("new wrap authority is not the default public key and not in the list - success", async () => { - // Setup the instruction - const { globalAccount } = await prepUpdateWrapAuthority(admin); - - // Send the transaction - await scaledUiExt.methods - .updateWrapAuthority(2, nonWrapAuthority.publicKey) - .accountsPartial({ ...accounts }) - .signers([admin]) - .rpc(); - - // Check that the wrap authority was added - const updatedWrapAuthorities = paddedWrapAuthorities.slice(0); - updatedWrapAuthorities[2] = nonWrapAuthority.publicKey; - - await expectExtGlobalState(globalAccount, { - wrapAuthorities: updatedWrapAuthorities, - }); - }); - }); - - describe("claim_fees 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 m vault is not the m vault PDA - // [X] it reverts with a ConstraintSeeds 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] given the m earn global account does not match the one on the global account - // [X] it reverts with a InvalidAccount 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 all the accounts are correct - // [X] given the multiplier is not synced - // [X] it syncs the multiplier to the current - // [X] given the m vault has excess collateral - // [X] it transfers the excess collateral to the recipient token account - // [X] given the m vault does not have excess collateral - // [X] it reverts with an InsufficientCollateral error - // [X] given the multiplier is already synced - // [X] given the m vault has excess collateral - // [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 - - const initialWrappedAmount = new BN(10_000_000); // 10 with 6 decimals - const wrapAuthorities = [admin.publicKey, wrapAuthority.publicKey]; - const fee_bps = new BN(randomInt(1, 10000)); // non-zero - const startIndex = new BN(randomInt(initialIndex.toNumber() + 1, 2e12)); - - beforeEach(async () => { - // Initialize the extension program - await initializeExt(wrapAuthorities, fee_bps); - - // Wrap some tokens from the admin to make the m vault's balance non-zero - await wrap(admin, initialWrappedAmount); - - // Propagate the start index - await propagateIndex(startIndex); - - // Claim yield for the m vault and complete the claim cycle - const mVault = getMVault(); - const mVaultATA = await getATA(mMint.publicKey, mVault); - await mClaimFor(mVault, await getTokenBalance(mVaultATA)); - await completeClaims(); - - // Sync the multiplier - await sync(); - - // Reset the blockhash to avoid issues with duplicate transactions - svm.expireBlockhash(); - }); - - // given the admin does not sign the transaction - // it reverts with a NotAuthorized error - test("admin does not sign - reverts", async () => { - // Setup the instruction - await prepClaimFees(nonAdmin); - - // Attempt to send the transaction - await expectAnchorError( - scaledUiExt.methods - .claimFees() - .accountsPartial({ ...accounts }) - .signers([nonAdmin]) - .rpc(), - "NotAuthorized" - ); - }); - - // given the m vault is not the m vault PDA - // it reverts with a ConstraintSeeds error - test("m vault is not the m vault PDA - reverts", async () => { - // Setup the instruction - await prepClaimFees(admin); - - // Change the m vault - accounts.mVault = PublicKey.unique(); - - // Attempt to send the transaction - await expectAnchorError( - scaledUiExt.methods - .claimFees() - .accountsPartial({ ...accounts }) - .signers([admin]) - .rpc(), - "ConstraintSeeds" - ); - }); - - // given the m vault token account is not the m vault PDA's ATA - // it reverts with a ConstraintAssociated error - test("m vault token account is not the m vault PDA's ATA - reverts", async () => { - // Create a token account for the M vault that is not the ATA - const { tokenAccount: nonAtaAccount } = await createTokenAccount( - mMint.publicKey, - getMVault() - ); - - // Setup the instruction - await prepClaimFees(admin); - accounts.vaultMTokenAccount = nonAtaAccount; - - // Attempt to send the transaction - await expectAnchorError( - scaledUiExt.methods - .claimFees() - .accountsPartial({ ...accounts }) - .signers([admin]) - .rpc(), - "ConstraintAssociated" - ); - }); - - // given the ext mint does not match the one on the global account - // it reverts with an InvalidMint error - test("ext mint does not match global account - reverts", async () => { - // Create a new mint - const wrongMint = new Keypair(); - await createMint(wrongMint, nonAdmin.publicKey, true, 6); - - // Setup the instruction - await prepClaimFees(admin); - accounts.extMint = wrongMint.publicKey; - - // Attempt to send the transaction - await expectAnchorError( - scaledUiExt.methods - .claimFees() - .accountsPartial({ ...accounts }) - .signers([admin]) - .rpc(), - "InvalidMint" - ); - }); - - // given the ext mint authority is not the ext mint authority PDA - // it reverts with a ConstraintSeeds error - test("ext mint authority is not the ext mint authority PDA - reverts", async () => { - // Setup the instruction - await prepClaimFees(admin); - - // Change the ext mint authority - accounts.extMintAuthority = PublicKey.unique(); - - // Attempt to send the transaction - await expectAnchorError( - scaledUiExt.methods - .claimFees() - .accountsPartial({ ...accounts }) - .signers([admin]) - .rpc(), - "ConstraintSeeds" - ); - }); - - // given the m earn global account does not match the one on the global account - // it reverts with a InvalidAccount error - test("m earn global account does not match global account - reverts", async () => { - // Setup the instruction - await prepClaimFees(admin); - - // Change the m earn global account - accounts.mEarnGlobalAccount = PublicKey.unique(); - - // Attempt to send the transaction - await expectSystemError( - scaledUiExt.methods - .claimFees() - .accountsPartial({ ...accounts }) - .signers([admin]) - .rpc() - ); - }); - - // given the recipient token account is not a token account for the ext mint - // it reverts with a ConstraintTokenMint error - test("recipient token account is not for ext mint - reverts", async () => { - // Create a token account for the m mint - const wrongTokenAccount = await getATA( - mMint.publicKey, - admin.publicKey - ); - - // Setup the instruction - await prepClaimFees(admin, wrongTokenAccount); - - // Attempt to send the transaction - await expectAnchorError( - scaledUiExt.methods - .claimFees() - .accountsPartial({ ...accounts }) - .signers([admin]) - .rpc(), - "ConstraintTokenMint" - ); - }); - - // given all accounts are correct - // given the multiplier is not synced - // it syncs the multiplier to the current - // given the m vault has excess collateral - // it transfers the excess collateral to the recipient token account - test("multiplier not synced, excess collateral exists - success", async () => { - // warp forward in time slightly - warp(new BN(60), true); - - // Propagate a new index to create a situation where multiplier needs sync - const newIndex = new BN(randomInt(startIndex.toNumber() + 1, 2e12)); - await propagateIndex(newIndex); - - // Claim yield to ensure vault has enough collateral - const mVault = getMVault(); - const mVaultATA = await getATA(mMint.publicKey, mVault); - await mClaimFor(mVault, await getTokenBalance(mVaultATA)); - await completeClaims(); - - // Cache balances before claim excess - const initialVaultBalance = await getTokenBalance(mVaultATA); - const recipientATA = await getATA(extMint.publicKey, admin.publicKey); - - // Get the global state before the update and calculate the expected excess - const globalState = await scaledUiExt.account.extGlobal.fetch( - getExtGlobalAccount() - ); - - const multiplier = - (globalState.lastExtIndex.toNumber() / 1e12) * - (newIndex.toNumber() / globalState.lastMIndex.toNumber()) ** - (1 - fee_bps.toNumber() / 1e4); - - const initialRecipientPrincipal = await getTokenBalance(recipientATA); - const initialRecipientBalance = await getTokenUiBalance( - recipientATA, - multiplier - ); - - const extSupply = await getMint( - provider.connection, - extMint.publicKey, - undefined, - TOKEN_2022_PROGRAM_ID - ).then((mint) => mint.supply); - - const requiredCollateral = new BN( - Math.ceil(Number(extSupply) * multiplier) - ); - - const expectedExcess = initialVaultBalance.sub(requiredCollateral); - const expectedExcessPrincipal = new BN( - Math.floor(Number(expectedExcess) / multiplier) - ); - - // Setup and execute the instruction - await prepClaimFees(admin); - await scaledUiExt.methods - .claimFees() - .accountsPartial({ ...accounts }) - .signers([admin]) - .rpc(); - - // Verify multiplier was updated - - expectScaledUiAmountConfig(extMint.publicKey, { - authority: getExtMintAuthority(), - multiplier, - newMultiplier: multiplier, - newMultiplierEffectiveTimestamp: BigInt(currentTime().toString()), - }); - - // Verify excess tokens were transferred - - expectTokenBalance(mVaultATA, initialVaultBalance); - expectTokenUiBalance( - recipientATA, - initialRecipientBalance.add(expectedExcess), - Comparison.LessThanOrEqual, - new BN(1) - ); - expectTokenBalance( - recipientATA, - initialRecipientPrincipal.add(expectedExcessPrincipal) - ); - }); - - // given all accounts are correct - // given the multiplier is already synced - // given the m vault has excess collateral - // it transfers the excess collateral to the recipient token account - test("multiplier already synced, excess collateral exists - success", async () => { - // Cache balances before claim excess - const mVaultATA = await getATA(mMint.publicKey, getMVault()); - const initialVaultBalance = await getTokenBalance(mVaultATA); - const recipientATA = await getATA(extMint.publicKey, admin.publicKey); - - // Get the global state and calculate the expected excess - const globalState = await scaledUiExt.account.extGlobal.fetch( - getExtGlobalAccount() - ); - - const multiplier = globalState.lastExtIndex.toNumber() / 1e12; - const initialRecipientBalance = await getTokenUiBalance( - recipientATA, - multiplier - ); - const initialRecipientPrincipal = await getTokenBalance(recipientATA); - - const extSupply = await getMint( - provider.connection, - extMint.publicKey, - undefined, - TOKEN_2022_PROGRAM_ID - ).then((mint) => mint.supply); - - const requiredCollateral = new BN( - Math.ceil(Number(extSupply) * multiplier) - ); - - const expectedExcess = initialVaultBalance.sub(requiredCollateral); - const expectedExcessPrincipal = new BN( - Math.floor(Number(expectedExcess) / multiplier) - ); - - // Setup and execute the instruction - await prepClaimFees(admin); - await scaledUiExt.methods - .claimFees() - .accountsPartial({ ...accounts }) - .signers([admin]) - .rpc(); - - // Verify excess tokens were transferred - expectTokenBalance(mVaultATA, initialVaultBalance); - expectTokenUiBalance( - recipientATA, - initialRecipientBalance.add(expectedExcess), - Comparison.LessThanOrEqual, - new BN(1) - ); - expectTokenBalance( - recipientATA, - initialRecipientPrincipal.add(expectedExcessPrincipal) - ); - }); - - // given all accounts are correct - // given the multiplier is not synced - // given the m vault does not have excess collateral - // it reverts with an InsufficientCollateral error - test("multiplier not synced, no excess collateral - reverts", async () => { - // claim the existing excess so there isn't extra - await claimFees(); - svm.expireBlockhash(); - - // Propagate a new index to create a situation where multiplier needs sync - const newIndex = new BN(randomInt(startIndex.toNumber() + 1, 2e12)); - await propagateIndex(newIndex); - - // Setup the instruction - await prepClaimFees(admin); - - // Attempt to send the transaction - await expectAnchorError( - scaledUiExt.methods - .claimFees() - .accountsPartial({ ...accounts }) - .signers([admin]) - .rpc(), - "InsufficientCollateral" - ); - }); - - // given all accounts are correct - // given the multiplier is already synced - // given the m vault does not have excess collateral - // it completes successfully and does not transfer any tokens - test("multiplier already synced, no excess collateral - success", async () => { - // claim the existing excess so there isn't extra - await claimFees(); - svm.expireBlockhash(); - - // Cache balances before claim excess - const mVaultATA = await getATA(mMint.publicKey, getMVault()); - const initialVaultBalance = await getTokenBalance(mVaultATA); - const recipientATA = await getATA(extMint.publicKey, admin.publicKey); - const initialRecipientBalance = await getTokenBalance(recipientATA); - - // Setup the instruction - await prepClaimFees(admin); - - // Attempt to send the transaction - await scaledUiExt.methods - .claimFees() - .accountsPartial({ ...accounts }) - .signers([admin]) - .rpc(); - - // Verify no tokens were transferred - expectTokenBalance(mVaultATA, initialVaultBalance); - expectTokenBalance(recipientATA, initialRecipientBalance); - }); - }); - }); - - describe("wrap_authority instruction tests", () => { - const mintAmount = new BN(100_000_000); // 100 with 6 decimals - const initialWrappedAmount = new BN(10_000_000); // 10 with 6 decimals - - const wrapAuthorities = [admin.publicKey, wrapAuthority.publicKey]; - const fee_bps = new BN(randomInt(10000)); - - const startIndex = new BN(randomInt(initialIndex.toNumber() + 1, 2e12)); - - // Setup accounts with M tokens so we can test wrapping and unwrapping - beforeEach(async () => { - // Initialize the extension program - await initializeExt(wrapAuthorities, fee_bps); - - // Mint M tokens to a wrap authority and a non-wrap authority - await mintM(wrapAuthority.publicKey, mintAmount); - await mintM(nonWrapAuthority.publicKey, mintAmount); - - // Wrap some tokens from the admin to the make the m vault's balance non-zero - await wrap(admin, initialWrappedAmount); - - // Propagate the start index - await propagateIndex(startIndex); - - // Claim yield for the m vault and complete the claim cycle - // so that the m vault is collateralized to start - const mVault = getMVault(); - const mVaultATA = await getATA(mMint.publicKey, mVault); - await mClaimFor(mVault, await getTokenBalance(mVaultATA)); - await completeClaims(); - - // Sync the scaled ui multiplier with the m index - await sync(); - - // Claim excess tokens to make it easier to test collateral checks - try { - await claimFees(); - } catch (e) { - // Ignore the error if there are no excess tokens - } - }); - - describe("wrap unit tests", () => { - describe("index same as start", () => { - // test cases - // [X] given the m mint account does not match the one stored in the global account - // [X] it reverts with an InvalidAccount error - // [X] given the ext mint account does not match the one stored in the global account - // [X] it reverts with an InvalidAccount error - // [X] given the signer is not the authority on the from m token account and is not delegated by the owner - // [X] it reverts with a ConstraintTokenOwner error - // [X] given the vault M token account is not the M Vaults ATA for the M token mint - // [X] it reverts with a ConstraintAssociated error - // [X] given the from m token account is for the wrong mint - // [X] it reverts with a ConstraintTokenMint error - // [X] given the to ext token account is for the wrong mint - // [X] it reverts with a ConstraintTokenMint error - // [X] given the signer is not in the wrap authorities list - // [X] it reverts with a ConstraintAuthority error - // [X] given all the accounts are correct - // [X] given the user does not have enough M tokens - // [X] it reverts with a ? error - // [X] given the user has enough M tokens - // [ ] given the signer is not the owner of the from M token account, but is delegated - // [ ] it transfers the amount of M tokens from the user's M token account to the M vault token account - // [X] given the signer is the owner of the from M token account - // [X] it transfers the amount of M tokens from the user's M token account to the M vault token account - // [X] it mints the amount of ext tokens to the user's ext token account - // [X] given the user wraps and then unwraps (roundtrip) - // [X] the starting balance and ending balance of the user's M token account are the same - - // given the m mint account does not match the one stored in the global account - // it reverts with an InvalidAccount error - test("M mint account does not match global account - reverts", async () => { - // Setup the instruction - await prepWrap(wrapAuthority); - - // Change the m mint account - accounts.mMint = extMint.publicKey; - - // Attempt to send the transaction - // Expect an invalid account error - await expectAnchorError( - scaledUiExt.methods - .wrap(mintAmount) - .accountsPartial({ ...accounts }) - .signers([wrapAuthority]) - .rpc(), - "InvalidAccount" - ); - }); - - // given the ext mint account does not match the one stored in the global account - // it reverts with an InvalidAccount error - test("Ext mint account does not match global account - reverts", async () => { - // Setup the instruction - await prepWrap(wrapAuthority); - - // Change the ext mint account - accounts.extMint = mMint.publicKey; - - // Attempt to send the transaction - // Expect an invalid account error - await expectAnchorError( - scaledUiExt.methods - .wrap(mintAmount) - .accountsPartial({ ...accounts }) - .signers([wrapAuthority]) - .rpc(), - "InvalidAccount" - ); - }); - - // given the signer is not the authority on the user M token account and is not delegated - // it reverts with a ConstraintTokenOwner error - test("Signer is not the authority on the from M token account and is not delegated - reverts", async () => { - // Get the ATA for another user - const wrongATA = await getATA( - mMint.publicKey, - nonWrapAuthority.publicKey - ); - - // Setup the instruction with the wrong user M token account - await prepWrap(wrapAuthority, undefined, wrongATA); - - // Attempt to send the transaction - // Expect revert with TokenOwner error - await expectSystemError( - scaledUiExt.methods - .wrap(mintAmount) - .accountsPartial({ ...accounts }) - .signers([wrapAuthority]) - .rpc() - ); - }); - - // given the M vault token account is not the M vault PDA's ATA - // it reverts with a ConstraintAssociated error - test("M Vault Token account is the the M Vault PDA's ATA (other token account) - reverts", async () => { - // Create a token account for the M vault that is not the ATA - const tokenAccountKeypair = Keypair.generate(); - const tokenAccountLen = getAccountLen([ExtensionType.ImmutableOwner]); - const lamports = - await provider.connection.getMinimumBalanceForRentExemption( - tokenAccountLen - ); - - const mVault = getMVault(); - - // Create token account with the immutable owner extension - const transaction = new Transaction().add( - SystemProgram.createAccount({ - fromPubkey: admin.publicKey, - newAccountPubkey: tokenAccountKeypair.publicKey, - space: tokenAccountLen, - lamports, - programId: TOKEN_2022_PROGRAM_ID, - }), - createInitializeImmutableOwnerInstruction( - tokenAccountKeypair.publicKey, - TOKEN_2022_PROGRAM_ID - ), - createInitializeAccountInstruction( - tokenAccountKeypair.publicKey, - mMint.publicKey, - mVault, - TOKEN_2022_PROGRAM_ID - ) - ); - - await provider.send!(transaction, [admin, tokenAccountKeypair]); - - // Setup the instruction with the non-ATA vault m token account - await prepWrap( - wrapAuthority, - undefined, - undefined, - undefined, - tokenAccountKeypair.publicKey - ); - - // Attempt to send the transaction - // Expect revert with a ConstraintAssociated error - await expectAnchorError( - scaledUiExt.methods - .wrap(mintAmount) - .accountsPartial({ ...accounts }) - .signers([wrapAuthority]) - .rpc(), - "ConstraintAssociated" - ); - }); - - // given the from m token account is for the wrong mint - // it reverts with a ConstraintTokenMint error - test("From M token account is for wrong mint - reverts", async () => { - // Get the user's ATA for the ext mint and pass it as the user M token account - const wrongUserATA = await getATA( - extMint.publicKey, - wrapAuthority.publicKey - ); - - // Setup the instruction - await prepWrap(wrapAuthority, undefined, wrongUserATA); - - // Attempt to send the transaction - // Expect revert with a ConstraintTokenMint error - await expectAnchorError( - scaledUiExt.methods - .wrap(mintAmount) - .accountsPartial({ ...accounts }) - .signers([wrapAuthority]) - .rpc(), - "ConstraintTokenMint" - ); - }); - - // given the to ext token account is for the wrong mint - // it reverts with a ConstraintTokenMint error - test("To Ext token account is for the wrong mint - reverts", async () => { - // Get the user's ATA for the m mint and pass it as the user ext token account - const wrongUserATA = await getATA( - mMint.publicKey, - wrapAuthority.publicKey - ); - - // Setup the instruction - await prepWrap(wrapAuthority, undefined, undefined, wrongUserATA); - - // Attempt to send the transaction - // Expect revert with a ConstraintTokenMint error - await expectAnchorError( - scaledUiExt.methods - .wrap(mintAmount) - .accountsPartial({ ...accounts }) - .signers([wrapAuthority]) - .rpc(), - "ConstraintTokenMint" - ); - }); - - // given the signer is not in the wrap authorities list - // it reverts with a NotAuthorized error - test("Signer is not in the wrap authorities list - reverts", async () => { - // Setup the instruction - await prepWrap(nonWrapAuthority); - - // Attempt to send the transaction - // Expect revert with a NotAuthorized error - await expectAnchorError( - scaledUiExt.methods - .wrap(mintAmount) - .accountsPartial({ ...accounts }) - .signers([nonWrapAuthority]) - .rpc(), - "NotAuthorized" - ); - }); - - // given all accounts are correct - // give the user does not have enough M tokens - // it reverts - test("Not enough M - reverts", async () => { - // Setup the instruction - await prepWrap(wrapAuthority); - - const wrapAmount = new BN( - randomInt(mintAmount.toNumber() + 1, 2 ** 48 - 1) - ); - - // Attempt to send the transaction - // Expect an error - await expectSystemError( - scaledUiExt.methods - .wrap(wrapAmount) - .accountsPartial({ ...accounts }) - .signers([wrapAuthority]) - .rpc() - ); - }); - - // given all accounts are correct - // given the from token account has enough M tokens - // given the signer is not the owner of the from M token account, but is delegated - // it transfers the amount of M tokens from the user's M token account to the M vault token account - // it mints the amount of ext tokens to the to ext token account - test("Wrap with delegated authority - success", async () => { - const wrapAmount = new BN(randomInt(1, mintAmount.toNumber() + 1)); - - // Approve (delegate) the wrap authority to spend the non-wrap authority's M tokens - const { sourceATA: fromATA } = await approve( - nonWrapAuthority, - wrapAuthority.publicKey, - mMint.publicKey, - wrapAmount - ); - - // Setup the instruction - const { vaultMTokenAccount, fromMTokenAccount, toExtTokenAccount } = - await prepWrap(wrapAuthority, nonWrapAuthority.publicKey, fromATA); - - // Cache initial balances - const fromMTokenAccountBalance = await getTokenBalance( - fromMTokenAccount - ); - const vaultMTokenAccountBalance = await getTokenBalance( - vaultMTokenAccount - ); - const toExtTokenAccountUiBalance = await getTokenUiBalance( - toExtTokenAccount - ); - - // Send the instruction - await scaledUiExt.methods - .wrap(wrapAmount) - .accountsPartial({ ...accounts }) - .signers([wrapAuthority]) - .rpc(); - - // Confirm updated balances - await expectTokenBalance( - fromMTokenAccount, - fromMTokenAccountBalance.sub(wrapAmount) - ); - await expectTokenBalance( - vaultMTokenAccount, - vaultMTokenAccountBalance.add(wrapAmount) - ); - await expectTokenUiBalance( - toExtTokenAccount, - toExtTokenAccountUiBalance.add(wrapAmount), - Comparison.LessThanOrEqual, - new BN(2) - ); - }); - - // given all accounts are correct - // given the user has enough M tokens - // it transfers the amount of M tokens from the user's M token account to the M vault token account - // it mints the amount of wM tokens to the user's wM token account - test("Wrap to wrap authority account - success", async () => { - // Setup the instruction - const { vaultMTokenAccount, fromMTokenAccount, toExtTokenAccount } = - await prepWrap(wrapAuthority); - - // Cache initial balances - const fromMTokenAccountBalance = await getTokenBalance( - fromMTokenAccount - ); - const vaultMTokenAccountBalance = await getTokenBalance( - vaultMTokenAccount - ); - const toExtTokenAccountUiBalance = await getTokenUiBalance( - toExtTokenAccount - ); - - const wrapAmount = new BN(randomInt(1, mintAmount.toNumber() + 1)); - - // Send the instruction - await scaledUiExt.methods - .wrap(wrapAmount) - .accountsPartial({ ...accounts }) - .signers([wrapAuthority]) - .rpc(); - - // Confirm updated balances - await expectTokenBalance( - fromMTokenAccount, - fromMTokenAccountBalance.sub(wrapAmount) - ); - await expectTokenBalance( - vaultMTokenAccount, - vaultMTokenAccountBalance.add(wrapAmount) - ); - await expectTokenUiBalance( - toExtTokenAccount, - toExtTokenAccountUiBalance.add(wrapAmount), - Comparison.LessThanOrEqual, - new BN(2) - ); - }); - - // given all accounts are correct - // given the user has enough M tokens - // given the signer does not own the to ext token account - // it transfers the amount of M tokens from the user's M token account to the M vault token account - // it mints the amount of wM tokens to the user's wM token account - test("Wrap to different account - success", async () => { - // Setup the instruction - const { vaultMTokenAccount, fromMTokenAccount, toExtTokenAccount } = - await prepWrap(wrapAuthority, nonWrapAuthority.publicKey); - - // Cache initial balances - const fromMTokenAccountBalance = await getTokenBalance( - fromMTokenAccount - ); - const vaultMTokenAccountBalance = await getTokenBalance( - vaultMTokenAccount - ); - const toExtTokenAccountUiBalance = await getTokenUiBalance( - toExtTokenAccount - ); - - const wrapAmount = new BN(randomInt(1, mintAmount.toNumber() + 1)); - - // Send the instruction - await scaledUiExt.methods - .wrap(wrapAmount) - .accountsPartial({ ...accounts }) - .signers([wrapAuthority]) - .rpc(); - - // Confirm updated balances - await expectTokenBalance( - fromMTokenAccount, - fromMTokenAccountBalance.sub(wrapAmount) - ); - await expectTokenBalance( - vaultMTokenAccount, - vaultMTokenAccountBalance.add(wrapAmount) - ); - await expectTokenUiBalance( - toExtTokenAccount, - toExtTokenAccountUiBalance.add(wrapAmount), - Comparison.LessThanOrEqual, - new BN(2) - ); - }); - - // given all accounts are correct - // given the user has enough M tokens - // round-trip (wrap / unwrap) - test("Wrap / unwrap roundtrip - success", async () => { - // Cache the starting balance of M - const wrapAuthorityATA = await getATA( - mMint.publicKey, - wrapAuthority.publicKey - ); - const startingBalance = await getTokenBalance(wrapAuthorityATA); - - // Wrap some tokens - const wrapAmount = new BN(randomInt(1, mintAmount.toNumber() + 1)); - await wrap(wrapAuthority, wrapAmount); - - // Unwrap the same amount - await unwrap(wrapAuthority, wrapAmount); - - // Confirm the final balance is the same as the starting balance - expectTokenBalance(wrapAuthorityATA, startingBalance); - }); - }); - - describe("index different from start (sync required)", () => { - // M Index is strictly increasing - const newIndex = new BN(randomInt(startIndex.toNumber() + 1, 2e12 + 1)); - - // console.log("new index", newIndex.toString()); - // console.log("start index", startIndex.toString()); - - beforeEach(async () => { - // Reset the blockhash to avoid issues with duplicate transactions from multiple claim cycles - svm.expireBlockhash(); - - // Propagate the new index - await propagateIndex(newIndex); - }); - - // test cases - // [X] given yield has not been minted to the m vault for the new index - // [X] it reverts with an InsufficientCollateral error - // [X] given yield has been minted to the m vault for the new index - // [X] it wraps the amount of M tokens from the user's M token account to the M vault token account - - // given yield has not been minted to the m vault for the new index - // it reverts with an InsufficientCollateral error - test("Yield not minted for new index - reverts", async () => { - // Setup the instruction - await prepWrap(wrapAuthority); - - const wrapAmount = new BN(randomInt(1, mintAmount.toNumber() + 1)); - - // const collateral = getTokenBalance( - // await getATA(mMint.publicKey, getMVault()) - // ); - // console.log("m vault balance before", (await collateral).toString()); - - // const extSupply = await getMint( - // provider.connection, - // extMint.publicKey, - // undefined, - // TOKEN_2022_PROGRAM_ID - // ).then((mint) => mint.supply); - - // console.log("ext supply before", extSupply.toString()); - - // const multiplierIncrease = - // (newIndex.toNumber() / startIndex.toNumber()) ** - // (1 - fee_bps.toNumber() / 1e4); - - // const lastMultiplier = - // ( - // await scaledUiExt.account.extGlobal.fetch(getExtGlobalAccount()) - // ).lastExtIndex.toNumber() / 1e12; - - // console.log("last multiplier", lastMultiplier.toString()); - - // const newMultiplier = lastMultiplier * multiplierIncrease; - - // console.log("new multiplier", newMultiplier.toString()); - - // console.log("required collateral", newMultiplier * Number(extSupply)); - - // Send the instruction - await expectAnchorError( - scaledUiExt.methods - .wrap(wrapAmount) - .accountsPartial({ ...accounts }) - .signers([wrapAuthority]) - .rpc(), - "InsufficientCollateral" - ); - }); - - // given yield has been minted to the m vault for the new index - // it wraps the amount of M tokens from the user's M token account to the M vault token account - test("Wrap with new index - success", async () => { - // Mint yield to the m vault for the new index - const mVault = getMVault(); - const mVaultATA = await getATA(mMint.publicKey, mVault); - await mClaimFor(mVault, await getTokenBalance(mVaultATA)); - await completeClaims(); - - // Setup the instruction - const { vaultMTokenAccount, fromMTokenAccount, toExtTokenAccount } = - await prepWrap(wrapAuthority); - - // Cache initial balances - const fromMTokenAccountBalance = await getTokenBalance( - fromMTokenAccount - ); - const vaultMTokenAccountBalance = await getTokenBalance( - vaultMTokenAccount - ); - const toExtTokenAccountUiBalance = await getTokenUiBalance( - toExtTokenAccount - ); - - const wrapAmount = new BN( - randomInt(1, fromMTokenAccountBalance.toNumber() + 1) - ); - - // Send the instruction - await scaledUiExt.methods - .wrap(wrapAmount) - .accountsPartial({ ...accounts }) - .signers([wrapAuthority]) - .rpc(); - - // Confirm updated balances - await expectTokenBalance( - fromMTokenAccount, - fromMTokenAccountBalance.sub(wrapAmount) - ); - await expectTokenBalance( - vaultMTokenAccount, - vaultMTokenAccountBalance.add(wrapAmount) - ); - await expectTokenUiBalance( - toExtTokenAccount, - toExtTokenAccountUiBalance.add(wrapAmount), - Comparison.LessThanOrEqual, - new BN(2) - ); - }); - }); - }); - - describe("unwrap unit tests", () => { - const wrappedAmount = new BN(25_000_000); - beforeEach(async () => { - // Wrap tokens for the users so we can test unwrapping - await wrap(wrapAuthority, wrappedAmount); - await wrap(wrapAuthority, wrappedAmount, nonWrapAuthority.publicKey); - }); - describe("index same as start", () => { - // test cases - // [X] given the m mint account does not match the one stored in the global account - // [X] it reverts with an InvalidAccount error - // [X] given the ext mint account does not match the one stored in the global account - // [X] it reverts with an InvalidAccount error - // [X] given the signer is not the authority on the from ext token account and is not delegated by the owner - // [X] it reverts with a Token program error - // [X] given the vault M token account is not the M Vaults ATA for the M token mint - // [X] it reverts with a ConstraintAssociated error - // [X] given the to m token account is for the wrong mint - // [X] it reverts with a ConstraintTokenMint error - // [X] given the from ext token account is for the wrong mint - // [X] it reverts with a ConstraintTokenMint error - // [X] given the signer is not in the wrap authorities list - // [X] it reverts with a ConstraintAuthority error - // [X] given all the accounts are correct - // [X] given the user does not have enough ext tokens - // [X] it reverts - // [X] given the user has enough ext tokens - // [X] given the signer is not the owner of the from ext token account, but is delegated - // [X] it burns the amount of ext tokens from the from's ext token account - // [X] given the signer is the owner of the from ext token account - // [X] it burns the amount of ext tokens from the user's ext token account - // [X] it transfers the amount of M tokens from the M vault token account to the to's M token account - - // given the m mint account does not match the one stored in the global account - // it reverts with an InvalidAccount error - test("M mint account does not match global account - reverts", async () => { - // Setup the instruction - await prepUnwrap(wrapAuthority); - - // Change the m mint account - accounts.mMint = extMint.publicKey; - - // Attempt to send the transaction - // Expect an invalid account error - await expectAnchorError( - scaledUiExt.methods - .unwrap(wrappedAmount) - .accountsPartial({ ...accounts }) - .signers([wrapAuthority]) - .rpc(), - "InvalidAccount" - ); - }); - - // given the ext mint account does not match the one stored in the global account - // it reverts with an InvalidAccount error - test("Ext mint account does not match global account - reverts", async () => { - // Setup the instruction - await prepUnwrap(wrapAuthority); - - // Change the ext mint account - accounts.extMint = mMint.publicKey; - - // Attempt to send the transaction - // Expect an invalid account error - await expectAnchorError( - scaledUiExt.methods - .unwrap(wrappedAmount) - .accountsPartial({ ...accounts }) - .signers([wrapAuthority]) - .rpc(), - "InvalidAccount" - ); - }); - - // given the signer is not the authority on the user ext token account and not delegated - // it reverts with a ConstraintTokenOwner error - test("Signer is not the authority on the from Ext token account and not delegated - reverts", async () => { - // Get the ATA for another user - const mATA = await getATA(mMint.publicKey, wrapAuthority.publicKey); - const wrongExtATA = await getATA( - extMint.publicKey, - nonWrapAuthority.publicKey - ); - - // Setup the instruction with the wrong user M token account - await prepUnwrap(wrapAuthority, undefined, mATA, wrongExtATA); - - // Attempt to send the transaction - // Expect revert with TokenOwner error - await expectSystemError( - scaledUiExt.methods - .unwrap(wrappedAmount) - .accountsPartial({ ...accounts }) - .signers([wrapAuthority]) - .rpc() - ); - }); - - // given the M vault token account is not the M vault PDA's ATA - // it reverts with a ConstraintAssociated error - test("M Vault Token account is the the M Vault PDA's ATA (other token account) - reverts", async () => { - // Create a token account for the M vault that is not the ATA - const tokenAccountKeypair = Keypair.generate(); - const tokenAccountLen = getAccountLen([ExtensionType.ImmutableOwner]); - const lamports = - await provider.connection.getMinimumBalanceForRentExemption( - tokenAccountLen - ); - - const mVault = getMVault(); - - // Create token account with the immutable owner extension - const transaction = new Transaction().add( - SystemProgram.createAccount({ - fromPubkey: admin.publicKey, - newAccountPubkey: tokenAccountKeypair.publicKey, - space: tokenAccountLen, - lamports, - programId: TOKEN_2022_PROGRAM_ID, - }), - createInitializeImmutableOwnerInstruction( - tokenAccountKeypair.publicKey, - TOKEN_2022_PROGRAM_ID - ), - createInitializeAccountInstruction( - tokenAccountKeypair.publicKey, - mMint.publicKey, - mVault, - TOKEN_2022_PROGRAM_ID - ) - ); - - await provider.send!(transaction, [admin, tokenAccountKeypair]); - - // Setup the instruction with the non-ATA vault m token account - await prepUnwrap( - wrapAuthority, - undefined, - undefined, - undefined, - tokenAccountKeypair.publicKey - ); - - // Attempt to send the transaction - // Expect revert with a ConstraintAssociated error - await expectAnchorError( - scaledUiExt.methods - .unwrap(wrappedAmount) - .accountsPartial({ ...accounts }) - .signers([wrapAuthority]) - .rpc(), - "ConstraintAssociated" - ); - }); - - // given the user m token account is for the wrong mint - // it reverts with a ConstraintTokenMint error - test("To M token account is for wrong mint - reverts", async () => { - // Get the user's ATA for the ext mint and pass it as the user M token account - const wrongUserATA = await getATA( - extMint.publicKey, - wrapAuthority.publicKey - ); - - // Setup the instruction - await prepUnwrap(wrapAuthority, undefined, wrongUserATA); - - // Attempt to send the transaction - // Expect revert with a ConstraintTokenMint error - await expectAnchorError( - scaledUiExt.methods - .unwrap(wrappedAmount) - .accountsPartial({ ...accounts }) - .signers([wrapAuthority]) - .rpc(), - "ConstraintTokenMint" - ); - }); - - // given the user ext token account is for the wrong mint - // it reverts with a ConstraintTokenMint error - test("From Ext token account is for the wrong mint - reverts", async () => { - // Get the user's ATA for the m mint and pass it as the user ext token account - const wrongUserATA = await getATA( - mMint.publicKey, - wrapAuthority.publicKey - ); - - // Setup the instruction - await prepUnwrap(wrapAuthority, undefined, undefined, wrongUserATA); - - // Attempt to send the transaction - // Expect revert with a ConstraintTokenMint error - await expectAnchorError( - scaledUiExt.methods - .unwrap(wrappedAmount) - .accountsPartial({ ...accounts }) - .signers([wrapAuthority]) - .rpc(), - "ConstraintTokenMint" - ); - }); - - // given the signer is not in the wrap authorities list - // it reverts with a NotAuthorized error - test("Signer is not in the wrap authorities list - reverts", async () => { - // Setup the instruction - await prepUnwrap(nonWrapAuthority); - - // Attempt to send the transaction - // Expect revert with a NotAuthorized error - await expectAnchorError( - scaledUiExt.methods - .unwrap(wrappedAmount) - .accountsPartial({ ...accounts }) - .signers([nonWrapAuthority]) - .rpc(), - "NotAuthorized" - ); - }); - - // given all accounts are correct - // give the user does not have enough ext tokens - // it reverts - test("Not enough ext tokens - reverts", async () => { - // Setup the instruction - await prepUnwrap(wrapAuthority); - - const unwrapAmount = new BN( - randomInt(wrappedAmount.toNumber() + 1, 2 ** 48 - 1) - ); - - // Attempt to send the transaction - // Expect an error - await expectSystemError( - scaledUiExt.methods - .unwrap(unwrapAmount) - .accountsPartial({ ...accounts }) - .signers([wrapAuthority]) - .rpc() - ); - }); - - // given all accounts are correct - // given the from token account has enough ext tokens - // given the signer is not the owner of the from ext token account, but is delegated - // it burns the amount of ext tokens from the from's ext token account - // it transfers the amount of M tokens from the M vault token account to the to's M token account - test("Unwrap with delegated authority - success", async () => { - const unwrapAmount = new BN( - randomInt(1, wrappedAmount.toNumber() + 1) - ); - - // Approve (delegate) the wrap authority to spend the non-wrap authority's ext tokens - const { sourceATA: fromExtTokenAccount } = await approve( - nonWrapAuthority, - wrapAuthority.publicKey, - extMint.publicKey, - unwrapAmount - ); - - // Setup the instruction - const { vaultMTokenAccount, toMTokenAccount } = await prepUnwrap( - wrapAuthority, - nonWrapAuthority.publicKey, - undefined, - fromExtTokenAccount - ); - - // Cache initial balances - const fromExtTokenAccountUiBalance = await getTokenUiBalance( - fromExtTokenAccount - ); - const vaultMTokenAccountBalance = await getTokenBalance( - vaultMTokenAccount - ); - const toMTokenAccountBalance = await getTokenBalance(toMTokenAccount); - - // Send the instruction - await scaledUiExt.methods - .unwrap(unwrapAmount) - .accountsPartial({ ...accounts }) - .signers([wrapAuthority]) - .rpc(); - - // Confirm updated balances - await expectTokenUiBalance( - fromExtTokenAccount, - fromExtTokenAccountUiBalance.sub(unwrapAmount), - Comparison.LessThanOrEqual, - new BN(2) - ); - await expectTokenBalance( - vaultMTokenAccount, - vaultMTokenAccountBalance.sub(unwrapAmount) - ); - await expectTokenBalance( - toMTokenAccount, - toMTokenAccountBalance.add(unwrapAmount) - ); - }); - - // given all accounts are correct - // given the user has enough ext tokens - // it transfers the amount of M tokens from the M vault token account to the user's M token account - // it burns the amount of ext tokens from the user's ext token account - test("Unwrap to wrap authority account - success", async () => { - // Setup the instruction - const { vaultMTokenAccount, toMTokenAccount, fromExtTokenAccount } = - await prepUnwrap(wrapAuthority); - - // Cache initial balances - const fromExtTokenAccountUiBalance = await getTokenUiBalance( - fromExtTokenAccount - ); - const vaultMTokenAccountBalance = await getTokenBalance( - vaultMTokenAccount - ); - const toMTokenAccountBalance = await getTokenBalance(toMTokenAccount); - - const unwrapAmount = new BN( - randomInt(1, wrappedAmount.toNumber() + 1) - ); - - // Send the instruction - await scaledUiExt.methods - .unwrap(unwrapAmount) - .accountsPartial({ ...accounts }) - .signers([wrapAuthority]) - .rpc(); - - // Confirm updated balances - await expectTokenBalance( - toMTokenAccount, - toMTokenAccountBalance.add(unwrapAmount) - ); - await expectTokenBalance( - vaultMTokenAccount, - vaultMTokenAccountBalance.sub(unwrapAmount) - ); - await expectTokenUiBalance( - fromExtTokenAccount, - fromExtTokenAccountUiBalance.sub(unwrapAmount), - Comparison.LessThanOrEqual, - new BN(2) - ); - }); - - // given all accounts are correct - // given the user has enough ext tokens - // it transfers the amount of M tokens from the M vault token account to the user's M token account - // it burns the amount of ext tokens from the user's ext token account - test("Unwrap to different account - success", async () => { - // Setup the instruction - const { vaultMTokenAccount, toMTokenAccount, fromExtTokenAccount } = - await prepUnwrap(wrapAuthority, nonWrapAuthority.publicKey); - - // Cache initial balances - const fromExtTokenAccountUiBalance = await getTokenUiBalance( - fromExtTokenAccount - ); - const vaultMTokenAccountBalance = await getTokenBalance( - vaultMTokenAccount - ); - const toMTokenAccountBalance = await getTokenBalance(toMTokenAccount); - - const unwrapAmount = new BN( - randomInt(1, wrappedAmount.toNumber() + 1) - ); - - // Send the instruction - await scaledUiExt.methods - .unwrap(unwrapAmount) - .accountsPartial({ ...accounts }) - .signers([wrapAuthority]) - .rpc(); - - // Confirm updated balances - await expectTokenBalance( - toMTokenAccount, - toMTokenAccountBalance.add(unwrapAmount) - ); - await expectTokenBalance( - vaultMTokenAccount, - vaultMTokenAccountBalance.sub(unwrapAmount) - ); - await expectTokenUiBalance( - fromExtTokenAccount, - fromExtTokenAccountUiBalance.sub(unwrapAmount), - Comparison.LessThanOrEqual, - new BN(2) - ); - }); - }); - - describe("index different from start (sync required)", () => { - const newIndex = new BN(randomInt(startIndex.toNumber() + 1, 2e12 + 1)); - let newMultiplier: number = 1.0; - - beforeEach(async () => { - // Reset the blockhash to avoid issues with duplicate transactions from multiple claim cycles - svm.expireBlockhash(); - - // Propagate the new index - await propagateIndex(newIndex); - - // Calculate the expected multipler after the new index push - const globalState = await scaledUiExt.account.extGlobal.fetch( - getExtGlobalAccount() - ); - newMultiplier = - (globalState.lastExtIndex.toNumber() / 1e12) * - (newIndex.toNumber() / startIndex.toNumber()) ** - (1 - fee_bps.toNumber() / 1e4); - }); - - // test cases - // [X] given yield has not been minted to the m vault for the new index - // [X] it reverts with an InsufficientCollateral error - // [X] given yield has been minted to the m vault for the new index - // [X] it unwraps the amount of M tokens from the M vault token account to the user's M token account - - // given yield has not been minted to the m vault for the new index - // it reverts with an InsufficientCollateral error - test("Yield not minted for new index - reverts", async () => { - // Setup the instruction - await prepUnwrap(wrapAuthority); - - const unwrapAmount = new BN( - randomInt(1, wrappedAmount.toNumber() + 1) - ); - - // Send the instruction - await expectAnchorError( - scaledUiExt.methods - .unwrap(unwrapAmount) - .accountsPartial({ ...accounts }) - .signers([wrapAuthority]) - .rpc(), - "InsufficientCollateral" - ); - }); - - // given yield has been minted to the m vault for the new index - // it unwraps the amount of M tokens from the M vault token account to the user's M token account - test("Unwrap with new index - success", async () => { - // Mint yield to the m vault for the new index - const mVault = getMVault(); - const mVaultATA = await getATA(mMint.publicKey, mVault); - await mClaimFor(mVault, await getTokenBalance(mVaultATA)); - await completeClaims(); - - // Setup the instruction - const { vaultMTokenAccount, toMTokenAccount, fromExtTokenAccount } = - await prepUnwrap(wrapAuthority); - - // Cache initial balances - const vaultMTokenAccountBalance = await getTokenBalance( - vaultMTokenAccount - ); - const toMTokenAccountBalance = await getTokenBalance(toMTokenAccount); - const postSyncFromExtTokenAccountUiBalance = await getTokenUiBalance( - fromExtTokenAccount, - newMultiplier - ); - - const unwrapAmount = new BN( - randomInt(1, postSyncFromExtTokenAccountUiBalance.toNumber() + 1) - ); - - // Send the instruction - await scaledUiExt.methods - .unwrap(unwrapAmount) - .accountsPartial({ ...accounts }) - .signers([wrapAuthority]) - .rpc(); - - // Confirm updated balances - await expectTokenBalance( - toMTokenAccount, - toMTokenAccountBalance.add(unwrapAmount) - ); - await expectTokenBalance( - vaultMTokenAccount, - vaultMTokenAccountBalance.sub(unwrapAmount) - ); - await expectTokenUiBalance( - fromExtTokenAccount, - postSyncFromExtTokenAccountUiBalance.sub(unwrapAmount), - Comparison.LessThanOrEqual, - new BN(2) - ); - }); - }); - }); - }); - - describe("open instruction tests", () => { - describe("sync unit tests", () => { - const initialWrappedAmount = new BN(10_000_000); // 10 with 6 decimals - - const wrapAuthorities = [admin.publicKey, wrapAuthority.publicKey]; - const fee_bps = new BN(randomInt(10000)); - - const startIndex = new BN(randomInt(initialIndex.toNumber() + 1, 2e12)); - - // Setup accounts with M tokens so we can test wrapping and unwrapping - beforeEach(async () => { - // Initialize the extension program - await initializeExt(wrapAuthorities, fee_bps); - - // Wrap some tokens from the admin to the make the m vault's balance non-zero - await wrap(admin, initialWrappedAmount); - - // Warp ahead slightly to change the timestamp of the new index - warp(new BN(60), true); - - // Propagate the start index - await propagateIndex(startIndex); - - // Claim yield for the m vault and complete the claim cycle - // so that the m vault is collateralized to start - const mVault = getMVault(); - const mVaultATA = await getATA(mMint.publicKey, mVault); - await mClaimFor(mVault, await getTokenBalance(mVaultATA)); - await completeClaims(); - - // Reset the blockhash to avoid issues with duplicate transactions from multiple claim cycles - svm.expireBlockhash(); - }); - - // test cases - // [X] given m earn global account does not match the one stored in the global account - // [X] it reverts with an InvalidAccount error - // [X] given the m vault account does not match the derived PDA - // [X] it reverts with a ConstraintSeeds error - // [X] given the vault m token account is not the M vault PDA's ATA - // [X] it reverts with a ConstraintAssociated error - // [X] given the ext mint account does not match the one stored in the global account - // [X] it reverts with an InvalidMint error - // [X] given the ext mint authority account does match the derived PDA - // [X] it reverts with a ConstraintSeeds error - // [X] given the multiplier is already up to date - // [X] it remains the same - // [X] given the multiplier is not up to date - // [X] given the m vault has not received yield to match the latest M index - // [X] it reverts with an InsufficientCollateral error - // [X] given the m vault has received yield to match the latest M index - // [X] it updates the scaled ui config on the ext mint to match the m index - - // given m earn global account does not match the one stored in the global account - // it reverts with an InvalidAccount error - test("M earn global account does not match global account - reverts", async () => { - // Setup the instruction - await prepSync(nonAdmin); - - // Change the m earn global account - accounts.mEarnGlobalAccount = PublicKey.unique(); - if (accounts.mEarnGlobalAccount.equals(getEarnGlobalAccount())) { - return; - } - - // Attempt to send the transaction - // Expect an invalid account error (though could be others like not initialized) - await expectSystemError( - scaledUiExt.methods - .sync() - .accountsPartial({ ...accounts }) - .signers([nonAdmin]) - .rpc() - ); - }); - - // given the m vault account does not match the derived PDA - // it reverts with a ConstraintSeeds error - test("M vault account does not match derived PDA - reverts", async () => { - // Setup the instruction - await prepSync(nonAdmin); - - // Change the m vault account - accounts.mVault = PublicKey.unique(); - if (accounts.mVault.equals(getMVault())) { - return; - } - - // Attempt to send the transaction - // Expect an invalid account error - await expectAnchorError( - scaledUiExt.methods - .sync() - .accountsPartial({ ...accounts }) - .signers([nonAdmin]) - .rpc(), - "ConstraintSeeds" - ); - }); - - // given the vault m token account is not the M vault PDA's ATA - // it reverts with a ConstraintAssociated error - test("M vault token account is not the M Vault PDA's ATA - reverts", async () => { - // Create a valid token account that is not the ATA - const { tokenAccount: nonATA } = await createTokenAccount( - mMint.publicKey, - getMVault() - ); - - // Setup the instruction with the non-ATA vault m token account - await prepSync(nonAdmin); - accounts.vaultMTokenAccount = nonATA; - - // Attempt to send the transaction - // Expect revert with a ConstraintAssociated error - await expectAnchorError( - scaledUiExt.methods - .sync() - .accountsPartial({ ...accounts }) - .signers([nonAdmin]) - .rpc(), - "ConstraintAssociated" - ); - }); - - // given the ext mint account does not match the one stored in the global account - // it reverts with an InvalidMint error - test("Ext mint account does not match global account - reverts", async () => { - // Create a new mint - const newMint = Keypair.generate(); - await createMint(newMint, nonAdmin.publicKey, true, 6); - - // Setup the instruction - await prepSync(nonAdmin); - - // Change the ext mint account - accounts.extMint = newMint.publicKey; - - // Attempt to send the transaction - // Expect an invalid account error - await expectAnchorError( - scaledUiExt.methods - .sync() - .accountsPartial({ ...accounts }) - .signers([nonAdmin]) - .rpc(), - "InvalidMint" - ); - }); - - // given the ext mint authority account does match the derived PDA - // it reverts with a ConstraintSeeds error - test("Ext mint authority account does not match derived PDA - reverts", async () => { - // Setup the instruction - await prepSync(nonAdmin); - - // Change the ext mint authority account - accounts.extMintAuthority = PublicKey.unique(); - if (accounts.extMintAuthority.equals(getExtMintAuthority())) { - return; - } - - // Attempt to send the transaction - // Expect an invalid account error - await expectAnchorError( - scaledUiExt.methods - .sync() - .accountsPartial({ ...accounts }) - .signers([nonAdmin]) - .rpc(), - "ConstraintSeeds" - ); - }); - - // given the multiplier is already up to date - // it remains the same - test("Multiplier is already up to date - success", async () => { - // Sync the multiplier to the start index - await sync(); - - // Load the scaled ui config - const scaledUiAmountConfig = await getScaledUiAmountConfig( - extMint.publicKey - ); - - svm.expireBlockhash(); - - // Sync again - await sync(); - - // Confirm the scaled ui config on the ext mint is the same - expectScaledUiAmountConfig(extMint.publicKey, scaledUiAmountConfig); - }); - - // given the multiplier is not up to date - // given the m vault has not received yield to match the latest M index - // it reverts with an InsufficientCollateral error - test("M vault has not received yield to match latest M index - reverts", async () => { - // Claim excess tokens to make it easier to test collateral checks - try { - await claimFees(); - } catch (e) { - // Ignore the error if there are no excess tokens - } - - // Propagate a new index but do not distribute yield yet - const newIndex = new BN(randomInt(startIndex.toNumber() + 1, 2e12 + 1)); - // console.log("new index", newIndex.toString()); - // console.log("start index", startIndex.toString()); - - await propagateIndex(newIndex); - - // Setup the instruction - await prepSync(nonAdmin); - - // Attempt to send the transaction - // Expect an invalid account error - await expectAnchorError( - scaledUiExt.methods - .sync() - .accountsPartial({ ...accounts }) - .signers([nonAdmin]) - .rpc(), - "InsufficientCollateral" - ); - }); - - // given the m vault has received yield to match the latest M index - // it updates the scaled ui config on the ext mint to match the m index - test("M vault has received yield to match latest M index - success", async () => { - // Cache the scaled ui amount config - const scaledUiAmountConfig = await getScaledUiAmountConfig( - extMint.publicKey - ); - - // Setup the instruction - const { globalAccount } = await prepSync(nonAdmin); - - // Get the global state before the update - const globalState = await scaledUiExt.account.extGlobal.fetch( - globalAccount - ); - - // Send the instruction - await scaledUiExt.methods - .sync() - .accountsPartial({ ...accounts }) - .signers([nonAdmin]) - .rpc(); - - // Confirm the scaled ui config on the ext mint matches the m index - // console.log("start index", startIndex.toString()); - // console.log("last ext index", globalState.lastExtIndex.toString()); - // console.log("last m index", globalState.lastMIndex.toString()); - // console.log("fee bps", fee_bps.toString()); - const multiplier = - (globalState.lastExtIndex.toNumber() / 1e12) * - (startIndex.toNumber() / globalState.lastMIndex.toNumber()) ** - (1 - fee_bps.toNumber() / 1e4); - // console.log("multiplier", multiplier.toString()); - - await expectScaledUiAmountConfig(extMint.publicKey, { - authority: scaledUiAmountConfig.authority, - multiplier, - newMultiplier: multiplier, - newMultiplierEffectiveTimestamp: BigInt(currentTime().toString()), - }); - }); - }); - }); -});