From 2fd6e2c4a960095164df245598cea30e03736416 Mon Sep 17 00:00:00 2001 From: Jon Gurary Date: Fri, 30 Aug 2024 15:52:43 -0400 Subject: [PATCH 01/52] More anchor e2e happy path tests --- .github/workflows/test.yaml | 58 +++---- package.json | 3 +- .../instructions/marginfi_group/configure.rs | 1 + .../marginfi_group/configure_bank.rs | 2 +- tests/01_initGroup.spec.ts | 6 +- tests/03_addBank.spec.ts | 16 +- tests/04_configureBank.spec.ts | 97 +++++++++++ tests/05_setupEmissions.spec.ts | 154 ++++++++++++++++++ tests/rootHooks.ts | 3 + tests/utils/genericTests.ts | 6 +- tests/utils/instructions.ts | 98 ++++++++++- tests/utils/pdas.ts | 14 ++ tests/utils/types.ts | 111 ++++++++++--- yarn.lock | 5 + 14 files changed, 512 insertions(+), 62 deletions(-) create mode 100644 tests/04_configureBank.spec.ts create mode 100644 tests/05_setupEmissions.spec.ts diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 519b28673..d7e1ce4d4 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -115,45 +115,45 @@ jobs: - name: Pass after fuzzing run: echo "Fuzzing completed" - # localnet-test-marginfi: - # name: Anchor localnet tests marginfi - # runs-on: ubuntu-latest + localnet-test-marginfi: + name: Anchor localnet tests marginfi + runs-on: ubuntu-latest - # steps: - # - uses: actions/checkout@v3 + steps: + - uses: actions/checkout@v3 - # - uses: ./.github/actions/setup-common/ - # - uses: ./.github/actions/setup-anchor-cli/ + - uses: ./.github/actions/setup-common/ + - uses: ./.github/actions/setup-anchor-cli/ - # - uses: ./.github/actions/build-workspace/ + - uses: ./.github/actions/build-workspace/ - # - name: Install Node.js dependencies - # run: yarn install + - name: Install Node.js dependencies + run: yarn install - # - name: Build marginfi program - # run: anchor build -p marginfi -- --no-default-features + - name: Build marginfi program + run: anchor build -p marginfi -- --no-default-features - # - name: Build liquidity incentive program - # run: anchor build -p liquidity_incentive_program -- --no-default-features + - name: Build liquidity incentive program + run: anchor build -p liquidity_incentive_program -- --no-default-features - # - name: Build mocks program - # run: anchor build -p mocks + - name: Build mocks program + run: anchor build -p mocks - # - name: Start Solana Test Validator - # run: | - # solana-test-validator --reset --limit-ledger-size 1000 \ + - name: Start Solana Test Validator + run: | + solana-test-validator --reset --limit-ledger-size 1000 \ - # - name: Wait for Validator to Start - # run: sleep 60 + - name: Wait for Validator to Start + run: sleep 60 - # - name: Deploy Liquidity Incentive Program - # run: solana program deploy --program-id Lip1111111111111111111111111111111111111111 target/deploy/liquidity_incentive_program.so + - name: Deploy Liquidity Incentive Program + run: solana program deploy --program-id Lip1111111111111111111111111111111111111111 target/deploy/liquidity_incentive_program.so - # - name: Deploy Marginfi Program - # run: solana program deploy --program-id 2jGhuVUuy3umdzByFx8sNWUAaf5vaeuDm78RDPEnhrMr target/deploy/marginfi.so + - name: Deploy Marginfi Program + run: solana program deploy --program-id 2jGhuVUuy3umdzByFx8sNWUAaf5vaeuDm78RDPEnhrMr target/deploy/marginfi.so - # - name: Deploy Mocks Program - # run: solana program deploy --program-id 5XaaR94jBubdbrRrNW7DtRvZeWvLhSHkEGU3jHTEXV3C target/deploy/mocks.so + - name: Deploy Mocks Program + run: solana program deploy --program-id 5XaaR94jBubdbrRrNW7DtRvZeWvLhSHkEGU3jHTEXV3C target/deploy/mocks.so - # - name: Run tests - # run: anchor test --skip-build --skip-local-validator + - name: Run tests + run: anchor test --skip-build --skip-local-validator diff --git a/package.json b/package.json index fdb1184eb..cb5ff8201 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "chai": "^4.3.4", "prettier": "^2.6.2", "ts-node": "^10.9.1", - "typescript": "^4.3.5" + "typescript": "^4.3.5", + "big.js": "^6.2.1" } } diff --git a/programs/marginfi/src/instructions/marginfi_group/configure.rs b/programs/marginfi/src/instructions/marginfi_group/configure.rs index e4f6e2b94..97099f1df 100644 --- a/programs/marginfi/src/instructions/marginfi_group/configure.rs +++ b/programs/marginfi/src/instructions/marginfi_group/configure.rs @@ -35,6 +35,7 @@ pub struct MarginfiGroupConfigure<'info> { pub marginfi_group: AccountLoader<'info, MarginfiGroup>, #[account( + // TODO moving to `marginfi_group` as `has_one` adds a mystery signer? address = marginfi_group.load()?.admin, )] pub admin: Signer<'info>, diff --git a/programs/marginfi/src/instructions/marginfi_group/configure_bank.rs b/programs/marginfi/src/instructions/marginfi_group/configure_bank.rs index 92a834409..900d31232 100644 --- a/programs/marginfi/src/instructions/marginfi_group/configure_bank.rs +++ b/programs/marginfi/src/instructions/marginfi_group/configure_bank.rs @@ -140,7 +140,7 @@ pub struct LendingPoolSetupEmissions<'info> { /// CHECK: Account provided only for funding rewards #[account(mut)] - pub emissions_funding_account: AccountInfo<'info>, + pub emissions_funding_account: AccountInfo<'info>, // TODO why isn't this TokenAccount? pub token_program: Interface<'info, TokenInterface>, pub system_program: Program<'info, System>, diff --git a/tests/01_initGroup.spec.ts b/tests/01_initGroup.spec.ts index 03a90dcdc..aba7c29ad 100644 --- a/tests/01_initGroup.spec.ts +++ b/tests/01_initGroup.spec.ts @@ -5,7 +5,7 @@ import { import { Transaction } from "@solana/web3.js"; import { groupInitialize } from "./utils/instructions"; import { Marginfi } from "../target/types/marginfi"; -import { groupAdmin, marginfiGroup } from "./rootHooks"; +import { groupAdmin, marginfiGroup, verbose } from "./rootHooks"; import { assertKeysEqual } from "./utils/genericTests"; describe("Init group", () => { @@ -29,5 +29,9 @@ describe("Init group", () => { marginfiGroup.publicKey ); assertKeysEqual(group.admin, groupAdmin.wallet.publicKey); + if(verbose){ + console.log("*init group: " + marginfiGroup.publicKey); + console.log(" group admin: " + group.admin); + } }); }); diff --git a/tests/03_addBank.spec.ts b/tests/03_addBank.spec.ts index d5597b6b0..67c371cc1 100644 --- a/tests/03_addBank.spec.ts +++ b/tests/03_addBank.spec.ts @@ -9,6 +9,7 @@ import { groupAdmin, marginfiGroup, oracles, + printBuffers, verbose, } from "./rootHooks"; import { @@ -59,7 +60,9 @@ describe("Lending pool add bank (add bank to group)", () => { let bankData = ( await program.provider.connection.getAccountInfo(bankKey) ).data.subarray(8); - printBufferGroups(bankData, 16, 896); + if (printBuffers) { + printBufferGroups(bankData, 16, 896); + } const bank = await program.account.bank.fetch(bankKey); const config = bank.config; @@ -110,6 +113,7 @@ describe("Lending pool add bank (add bank to group)", () => { assertI80F48Equal(config.assetWeightInit, 1); assertI80F48Equal(config.assetWeightMaint, 1); assertI80F48Equal(config.liabilityWeightInit, 1); + assertI80F48Equal(config.liabilityWeightMaint, 1); assertBNEqual(config.depositLimit, 1_000_000_000); const tolerance = 0.000001; @@ -164,7 +168,9 @@ describe("Lending pool add bank (add bank to group)", () => { let bonkBankData = ( await program.provider.connection.getAccountInfo(bonkBankKey) ).data.subarray(8); - printBufferGroups(bonkBankData, 16, 896); + if (printBuffers) { + printBufferGroups(bonkBankData, 16, 896); + } let cloudBankKey = new PublicKey( "4kNXetv8hSv9PzvzPZzEs1CTH6ARRRi2b8h6jk1ad1nP" @@ -172,7 +178,9 @@ describe("Lending pool add bank (add bank to group)", () => { let cloudBankData = ( await program.provider.connection.getAccountInfo(cloudBankKey) ).data.subarray(8); - printBufferGroups(cloudBankData, 16, 896); + if (printBuffers) { + printBufferGroups(cloudBankData, 16, 896); + } const bbk = bonkBankKey; const bb = await program.account.bank.fetch(bonkBankKey); @@ -291,3 +299,5 @@ describe("Lending pool add bank (add bank to group)", () => { assert.equal(cloudConfig.oracleMaxAge, 60); }); }); + +// TODO add bank with seed \ No newline at end of file diff --git a/tests/04_configureBank.spec.ts b/tests/04_configureBank.spec.ts new file mode 100644 index 000000000..272955ef2 --- /dev/null +++ b/tests/04_configureBank.spec.ts @@ -0,0 +1,97 @@ +import { BN, Program, workspace } from "@coral-xyz/anchor"; +import { Transaction } from "@solana/web3.js"; +import { configureBank } from "./utils/instructions"; +import { Marginfi } from "../target/types/marginfi"; +import { bankKeypairUsdc, groupAdmin, marginfiGroup } from "./rootHooks"; +import { assertBNEqual, assertI80F48Approx } from "./utils/genericTests"; +import { assert } from "chai"; +import { + BankConfigOptRaw, + InterestRateConfigRaw, +} from "@mrgnlabs/marginfi-client-v2"; +import { bigNumberToWrappedI80F48 } from "@mrgnlabs/mrgn-common"; +import { defaultBankConfigOptRaw } from "./utils/types"; + +describe("Lending pool configure bank", () => { + const program = workspace.Marginfi as Program; + + it("(admin) Configure bank (USDC) - happy path", async () => { + const bankKey = bankKeypairUsdc.publicKey; + let interestRateConfig: InterestRateConfigRaw = { + optimalUtilizationRate: bigNumberToWrappedI80F48(0.1), + plateauInterestRate: bigNumberToWrappedI80F48(0.2), + maxInterestRate: bigNumberToWrappedI80F48(4), + insuranceFeeFixedApr: bigNumberToWrappedI80F48(0.3), + insuranceIrFee: bigNumberToWrappedI80F48(0.4), + protocolFixedFeeApr: bigNumberToWrappedI80F48(0.5), + protocolIrFee: bigNumberToWrappedI80F48(0.6), + }; + + let bankConfigOpt: BankConfigOptRaw = { + assetWeightInit: bigNumberToWrappedI80F48(0.6), + assetWeightMaint: bigNumberToWrappedI80F48(0.7), + liabilityWeightInit: bigNumberToWrappedI80F48(1.9), + liabilityWeightMaint: bigNumberToWrappedI80F48(1.8), + depositLimit: new BN(5000), + borrowLimit: new BN(10000), + riskTier: null, + totalAssetValueInitLimit: new BN(15000), + interestRateConfig: interestRateConfig, + operationalState: { + paused: undefined, + }, + oracle: null, + oracleMaxAge: 50, + permissionlessBadDebtSettlement: null, + }; + + await groupAdmin.userMarginProgram!.provider.sendAndConfirm!( + new Transaction().add( + await configureBank(program, { + marginfiGroup: marginfiGroup.publicKey, + admin: groupAdmin.wallet.publicKey, + bank: bankKey, + bankConfigOpt: bankConfigOpt, + }) + ) + ); + + const bank = await program.account.bank.fetch(bankKey); + const config = bank.config; + const interest = config.interestRateConfig; + + assertI80F48Approx(config.assetWeightInit, 0.6); + assertI80F48Approx(config.assetWeightMaint, 0.7); + assertI80F48Approx(config.liabilityWeightInit, 1.9); + assertI80F48Approx(config.liabilityWeightMaint, 1.8); + assertBNEqual(config.depositLimit, 5000); + + assertI80F48Approx(interest.optimalUtilizationRate, 0.1); + assertI80F48Approx(interest.plateauInterestRate, 0.2); + assertI80F48Approx(interest.maxInterestRate, 4); + assertI80F48Approx(interest.insuranceFeeFixedApr, 0.3); + assertI80F48Approx(interest.insuranceIrFee, 0.4); + assertI80F48Approx(interest.protocolFixedFeeApr, 0.5); + assertI80F48Approx(interest.protocolIrFee, 0.6); + + assert.deepEqual(config.operationalState, { paused: {} }); + assert.deepEqual(config.oracleSetup, { pythLegacy: {} }); // no change + assertBNEqual(config.borrowLimit, 10000); + assert.deepEqual(config.riskTier, { collateral: {} }); // no change + assertBNEqual(config.totalAssetValueInitLimit, 15000); + assert.equal(config.oracleMaxAge, 50); + }); + + it("(admin) Restore default settings to bank (USDC)", async () => { + await groupAdmin.userMarginProgram!.provider.sendAndConfirm!( + new Transaction().add( + await configureBank(program, { + marginfiGroup: marginfiGroup.publicKey, + admin: groupAdmin.wallet.publicKey, + bank: bankKeypairUsdc.publicKey, + bankConfigOpt: defaultBankConfigOptRaw(), + }) + ) + ); + }); +}); diff --git a/tests/05_setupEmissions.spec.ts b/tests/05_setupEmissions.spec.ts new file mode 100644 index 000000000..0da9f1dea --- /dev/null +++ b/tests/05_setupEmissions.spec.ts @@ -0,0 +1,154 @@ +import { + AnchorProvider, + BN, + getProvider, + Program, + Wallet, + workspace, +} from "@coral-xyz/anchor"; +import { Transaction } from "@solana/web3.js"; +import { setupEmissions, updateEmissions } from "./utils/instructions"; +import { Marginfi } from "../target/types/marginfi"; +import { + bankKeypairUsdc, + ecosystem, + groupAdmin, + marginfiGroup, + verbose, +} from "./rootHooks"; +import { + assertBNEqual, + assertI80F48Approx, + assertKeysEqual, + getTokenBalance, +} from "./utils/genericTests"; +import { assert } from "chai"; +import { + EMISSIONS_FLAG_BORROW_ACTIVE, + EMISSIONS_FLAG_LENDING_ACTIVE, +} from "./utils/types"; +import { createMintToInstruction } from "@solana/spl-token"; +import { deriveEmissionsAuth, deriveEmissionsTokenAccount } from "./utils/pdas"; + +describe("Lending pool set up emissions", () => { + const program = workspace.Marginfi as Program; + const provider = getProvider() as AnchorProvider; + const wallet = provider.wallet as Wallet; + + const emissionRate = new BN(500_000 * 10 ** ecosystem.tokenBDecimals); + const totalEmissions = new BN(1_000_000 * 10 ** ecosystem.tokenBDecimals); + + it("Mint token B to the group admin for funding emissions", async () => { + let tx: Transaction = new Transaction(); + tx.add( + createMintToInstruction( + ecosystem.tokenBMint.publicKey, + groupAdmin.tokenBAccount, + wallet.publicKey, + BigInt(100_000_000) * BigInt(10 ** ecosystem.tokenBDecimals) + ) + ); + await program.provider.sendAndConfirm(tx); + }); + + it("(admin) Set up to token B emissions on (USDC) bank - happy path", async () => { + const adminBBefore = await getTokenBalance( + provider, + groupAdmin.tokenBAccount + ); + const [emissionsAccKey] = deriveEmissionsTokenAccount( + program.programId, + bankKeypairUsdc.publicKey, + ecosystem.tokenBMint.publicKey + ); + // Note: an uninitialized account that does nothing... + const [emissionsAuthKey] = deriveEmissionsAuth( + program.programId, + bankKeypairUsdc.publicKey, + ecosystem.tokenBMint.publicKey + ); + + await groupAdmin.userMarginProgram!.provider.sendAndConfirm!( + new Transaction().add( + await setupEmissions(program, { + marginfiGroup: marginfiGroup.publicKey, + admin: groupAdmin.wallet.publicKey, + bank: bankKeypairUsdc.publicKey, + emissionsMint: ecosystem.tokenBMint.publicKey, + fundingAccount: groupAdmin.tokenBAccount, + emissionsFlags: new BN( + EMISSIONS_FLAG_BORROW_ACTIVE + EMISSIONS_FLAG_LENDING_ACTIVE + ), + emissionsRate: emissionRate, + totalEmissions: totalEmissions, + }) + ) + ); + + if (verbose) { + console.log("Started token B borrow/lending emissions on USDC bank"); + } + + const [bank, adminBAfter, emissionsAccAfter] = await Promise.all([ + program.account.bank.fetch(bankKeypairUsdc.publicKey), + getTokenBalance(provider, groupAdmin.tokenBAccount), + getTokenBalance(provider, emissionsAccKey), + ]); + + assertKeysEqual(bank.emissionsMint, ecosystem.tokenBMint.publicKey); + assertBNEqual(bank.emissionsRate, emissionRate); + assertI80F48Approx(bank.emissionsRemaining, totalEmissions); + assertBNEqual( + bank.flags, + new BN(EMISSIONS_FLAG_BORROW_ACTIVE + EMISSIONS_FLAG_LENDING_ACTIVE) + ); + assert.equal(adminBBefore - adminBAfter, totalEmissions.toNumber()); + assert.equal(emissionsAccAfter, totalEmissions.toNumber()); + }); + + it("(admin) Add more token B emissions on (USDC) bank - happy path", async () => { + const [emissionsAccKey] = deriveEmissionsTokenAccount( + program.programId, + bankKeypairUsdc.publicKey, + ecosystem.tokenBMint.publicKey + ); + const [adminBBefore, emissionsAccBefore] = await Promise.all([ + getTokenBalance(provider, groupAdmin.tokenBAccount), + getTokenBalance(provider, emissionsAccKey), + ]); + + await groupAdmin.userMarginProgram!.provider.sendAndConfirm!( + new Transaction().add( + await updateEmissions(program, { + marginfiGroup: marginfiGroup.publicKey, + admin: groupAdmin.wallet.publicKey, + bank: bankKeypairUsdc.publicKey, + emissionsMint: ecosystem.tokenBMint.publicKey, + fundingAccount: groupAdmin.tokenBAccount, + emissionsFlags: null, + emissionsRate: null, + additionalEmissions: totalEmissions, + }) + ) + ); + + const [bank, adminBAfter, emissionsAccAfter] = await Promise.all([ + program.account.bank.fetch(bankKeypairUsdc.publicKey), + getTokenBalance(provider, groupAdmin.tokenBAccount), + getTokenBalance(provider, emissionsAccKey), + ]); + + assertKeysEqual(bank.emissionsMint, ecosystem.tokenBMint.publicKey); + assertBNEqual(bank.emissionsRate, emissionRate); + assertI80F48Approx(bank.emissionsRemaining, totalEmissions.muln(2)); + assertBNEqual( + bank.flags, + new BN(EMISSIONS_FLAG_BORROW_ACTIVE + EMISSIONS_FLAG_LENDING_ACTIVE) + ); + assert.equal(adminBBefore - adminBAfter, totalEmissions.toNumber()); + assert.equal( + emissionsAccAfter, + emissionsAccBefore + totalEmissions.toNumber() + ); + }); +}); diff --git a/tests/rootHooks.ts b/tests/rootHooks.ts index bf72966a7..295a709fc 100644 --- a/tests/rootHooks.ts +++ b/tests/rootHooks.ts @@ -15,7 +15,10 @@ import { setupPythOracles } from "./utils/pyth_mocks"; export const ecosystem: Ecosystem = getGenericEcosystem(); export let oracles: Oracles = undefined; +/** Show various information about accounts and tests */ export const verbose = true; +/** Show the raw buffer printout of various structs */ +export const printBuffers = false; /** The program owner is also the provider wallet */ export let globalProgramAdmin: mockUser = undefined; export let groupAdmin: mockUser = undefined; diff --git a/tests/utils/genericTests.ts b/tests/utils/genericTests.ts index 3923219b1..eaeabb374 100644 --- a/tests/utils/genericTests.ts +++ b/tests/utils/genericTests.ts @@ -64,15 +64,15 @@ export const assertI80F48Equal = ( }; /** - * Shorthand to convert I80F48 to a string and compare against a BN, number, or other WrappedI80F48 within a given tolerance + * Shorthand to convert I80F48 to a BigNumber and compare against a BN, number, or other WrappedI80F48 within a given tolerance * @param a * @param b - * @param tolerance - the allowed difference between the two values + * @param tolerance - the allowed difference between the two values (default .000001) */ export const assertI80F48Approx = ( a: WrappedI80F48, b: WrappedI80F48 | BN | number, - tolerance: number + tolerance: number = .000001 ) => { const bigA = wrappedI80F48toBigNumber(a); let bigB: BigNumber; diff --git a/tests/utils/instructions.ts b/tests/utils/instructions.ts index 10c1f9241..5cba951ee 100644 --- a/tests/utils/instructions.ts +++ b/tests/utils/instructions.ts @@ -1,4 +1,4 @@ -import { Program } from "@coral-xyz/anchor"; +import { BN, Program } from "@coral-xyz/anchor"; import { AccountMeta, PublicKey, SYSVAR_RENT_PUBKEY } from "@solana/web3.js"; import { Marginfi } from "../../target/types/marginfi"; import { @@ -11,6 +11,7 @@ import { } from "./pdas"; import { BankConfig } from "./types"; import { TOKEN_PROGRAM_ID } from "@solana/spl-token"; +import { BankConfigOptRaw } from "@mrgnlabs/marginfi-client-v2"; export const MAX_ORACLE_KEYS = 5; @@ -51,6 +52,7 @@ export const addBank = (program: Program, args: AddBankArgs) => { oracleKey: args.config.oracleKey, borrowLimit: args.config.borrowLimit, riskTier: args.config.riskTier, + pad0: [0, 0, 0, 0, 0, 0, 0], totalAssetValueInitLimit: args.config.totalAssetValueInitLimit, oracleMaxAge: args.config.oracleMaxAge, }) @@ -121,3 +123,97 @@ export const groupInitialize = ( return ix; }; + +export type ConfigureBankArgs = { + marginfiGroup: PublicKey; + admin: PublicKey; + bank: PublicKey; + bankConfigOpt: BankConfigOptRaw; +}; + +export const configureBank = ( + program: Program, + args: ConfigureBankArgs +) => { + const ix = program.methods + .lendingPoolConfigureBank(args.bankConfigOpt) + .accounts({ + marginfiGroup: args.marginfiGroup, + admin: args.admin, + bank: args.bank, + }) + .instruction(); + return ix; +}; + +export type SetupEmissionsArgs = { + marginfiGroup: PublicKey; + admin: PublicKey; + bank: PublicKey; + emissionsMint: PublicKey; + fundingAccount: PublicKey; + emissionsFlags: BN; + emissionsRate: BN; + totalEmissions: BN; +}; + +export const setupEmissions = ( + program: Program, + args: SetupEmissionsArgs +) => { + const ix = program.methods + .lendingPoolSetupEmissions( + args.emissionsFlags, + args.emissionsRate, + args.totalEmissions + ) + .accounts({ + marginfiGroup: args.marginfiGroup, + admin: args.admin, + bank: args.bank, + emissionsMint: args.emissionsMint, + // emissionsAuth: deriveEmissionsAuth() + // emissionsTokenAccount: deriveEmissionsTokenAccount() + emissionsFundingAccount: args.fundingAccount, + tokenProgram: TOKEN_PROGRAM_ID, + // systemProgram: SystemProgram.programId, + }) + .instruction(); + return ix; +}; + +export type UpdateEmissionsArgs = { + marginfiGroup: PublicKey; + admin: PublicKey; + bank: PublicKey; + emissionsMint: PublicKey; + fundingAccount: PublicKey; + emissionsFlags: BN | null; + emissionsRate: BN | null; + additionalEmissions: BN | null; +}; + +export const updateEmissions = ( + program: Program, + args: UpdateEmissionsArgs +) => { + const ix = program.methods + .lendingPoolUpdateEmissionsParameters( + args.emissionsFlags, + args.emissionsRate, + args.additionalEmissions + ) + .accounts({ + marginfiGroup: args.marginfiGroup, + admin: args.admin, + bank: args.bank, + emissionsMint: args.emissionsMint, + // emissionsAuth: deriveEmissionsAuth() + // emissionsTokenAccount: deriveEmissionsTokenAccount() + emissionsFundingAccount: args.fundingAccount, + tokenProgram: TOKEN_PROGRAM_ID, + // systemProgram: SystemProgram.programId, + }) + .instruction(); + return ix; +}; \ No newline at end of file diff --git a/tests/utils/pdas.ts b/tests/utils/pdas.ts index 2594ccfad..2c91f1593 100644 --- a/tests/utils/pdas.ts +++ b/tests/utils/pdas.ts @@ -50,3 +50,17 @@ export const deriveFeeVault = (programId: PublicKey, bank: PublicKey) => { programId ); }; + +export const deriveEmissionsAuth = (programId: PublicKey, bank: PublicKey, mint: PublicKey) => { + return PublicKey.findProgramAddressSync( + [Buffer.from("emissions_auth_seed", "utf-8"), bank.toBuffer(), mint.toBuffer()], + programId + ); +}; + +export const deriveEmissionsTokenAccount = (programId: PublicKey, bank: PublicKey, mint: PublicKey) => { + return PublicKey.findProgramAddressSync( + [Buffer.from("emissions_token_account_seed", "utf-8"), bank.toBuffer(), mint.toBuffer()], + programId + ); +}; diff --git a/tests/utils/types.ts b/tests/utils/types.ts index 078dc781a..fdc387985 100644 --- a/tests/utils/types.ts +++ b/tests/utils/types.ts @@ -1,24 +1,31 @@ +import { + BankConfigOpt, + BankConfigOptRaw, + InterestRateConfig, + InterestRateConfigRaw, + OperationalState, + OracleSetupRaw, + RiskTier, + RiskTierRaw, +} from "@mrgnlabs/marginfi-client-v2"; import { bigNumberToWrappedI80F48, WrappedI80F48 } from "@mrgnlabs/mrgn-common"; import { PublicKey } from "@solana/web3.js"; +import BigNumber from "bignumber.js"; import BN from "bn.js"; export const I80F48_ZERO = bigNumberToWrappedI80F48(0); export const I80F48_ONE = bigNumberToWrappedI80F48(1); -export type RiskTier = { collateral: {} } | { isolated: {} }; +export const EMISSIONS_FLAG_NONE = 0; +export const EMISSIONS_FLAG_BORROW_ACTIVE = 1; +export const EMISSIONS_FLAG_LENDING_ACTIVE = 2; -export type OperationalState = +type OperationalStateRaw = | { paused: {} } | { operational: {} } | { reduceOnly: {} }; -export type OracleSetup = - | { none: {} } - | { pythLegacy: {} } - | { switchboardV2: {} } - | { pythPushOracle: {} }; - export type BankConfig = { assetWeightInit: WrappedI80F48; assetWeightMaint: WrappedI80F48; @@ -27,18 +34,18 @@ export type BankConfig = { liabilityWeightMain: WrappedI80F48; depositLimit: BN; - interestRateConfig: InterestRateConfig; + interestRateConfig: InterestRateConfigRaw; /** Paused = 0, Operational = 1, ReduceOnly = 2 */ - operationalState: OperationalState; + operationalState: OperationalStateRaw; /** None = 0, PythLegacy = 1, SwitchboardV2 = 2, PythPushOracle =3 */ - oracleSetup: OracleSetup; + oracleSetup: OracleSetupRaw; oracleKey: PublicKey; borrowLimit: BN; /** Collateral = 0, Isolated = 1 */ - riskTier: RiskTier; + riskTier: RiskTierRaw; totalAssetValueInitLimit: BN; oracleMaxAge: number; }; @@ -59,7 +66,7 @@ export const defaultBankConfig = (oracleKey: PublicKey) => { liabilityWeightInit: I80F48_ONE, liabilityWeightMain: I80F48_ONE, depositLimit: new BN(1_000_000_000), - interestRateConfig: defaultInterestRateConfig(), + interestRateConfig: defaultInterestRateConfigRaw(), operationalState: { operational: undefined, }, @@ -77,15 +84,56 @@ export const defaultBankConfig = (oracleKey: PublicKey) => { return config; }; -export type InterestRateConfig = { - optimalUtilizationRate: WrappedI80F48; - plateauInterestRate: WrappedI80F48; - maxInterestRate: WrappedI80F48; +/** + * The same parameters as `defaultBankConfig`, and no change to oracle + * @returns + */ +export const defaultBankConfigOpt = () => { + let bankConfigOpt: BankConfigOpt = { + assetWeightInit: new BigNumber(1), + assetWeightMaint: new BigNumber(1), + liabilityWeightInit: new BigNumber(1), + liabilityWeightMaint: new BigNumber(1), + depositLimit: new BigNumber(1_000_000_000), + borrowLimit: new BigNumber(1_000_000_000), + riskTier: RiskTier.Collateral, + totalAssetValueInitLimit: new BigNumber(100_000_000_000), + interestRateConfig: defaultInterestRateConfig(), + operationalState: OperationalState.Operational, + oracle: null, + oracleMaxAge: 100, + permissionlessBadDebtSettlement: null, + }; - insuranceFeeFixedApr: WrappedI80F48; - insuranceIrFee: WrappedI80F48; - protocolFixedFeeApr: WrappedI80F48; - protocolIrFee: WrappedI80F48; + return bankConfigOpt; +}; + +/** + * The same parameters as `defaultBankConfig`, and no change to oracle + * @returns + */ +export const defaultBankConfigOptRaw = () => { + let bankConfigOpt: BankConfigOptRaw = { + assetWeightInit: I80F48_ONE, + assetWeightMaint: I80F48_ONE, + liabilityWeightInit: I80F48_ONE, + liabilityWeightMaint: I80F48_ONE, + depositLimit: new BN(1_000_000_000), + borrowLimit: new BN(1_000_000_000), + riskTier: { + collateral: undefined, + }, + totalAssetValueInitLimit: new BN(100_000_000_000), + interestRateConfig: defaultInterestRateConfigRaw(), + operationalState: { + operational: undefined, + }, + oracle: null, + oracleMaxAge: 100, + permissionlessBadDebtSettlement: null, + }; + + return bankConfigOpt; }; /** @@ -96,8 +144,8 @@ export type InterestRateConfig = { * * All others values = 0 * @returns */ -export const defaultInterestRateConfig = () => { - let config: InterestRateConfig = { +export const defaultInterestRateConfigRaw = () => { + let config: InterestRateConfigRaw = { optimalUtilizationRate: bigNumberToWrappedI80F48(0.5), plateauInterestRate: bigNumberToWrappedI80F48(0.6), maxInterestRate: bigNumberToWrappedI80F48(3), @@ -108,3 +156,20 @@ export const defaultInterestRateConfig = () => { }; return config; }; + +/** + * The same parameters as `defaultInterestRateConfigRaw` + * @returns + */ +export const defaultInterestRateConfig = () => { + let config: InterestRateConfig = { + optimalUtilizationRate: new BigNumber(0.5), + plateauInterestRate: new BigNumber(0.6), + maxInterestRate: new BigNumber(3), + insuranceFeeFixedApr: new BigNumber(0), + insuranceIrFee: new BigNumber(0), + protocolFixedFeeApr: new BigNumber(0), + protocolIrFee: new BigNumber(0), + }; + return config; +}; diff --git a/yarn.lock b/yarn.lock index 7590eb8dd..98f03b3c9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -715,6 +715,11 @@ base64-js@^1.3.1: resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== +big.js@^6.2.1: + version "6.2.1" + resolved "https://registry.yarnpkg.com/big.js/-/big.js-6.2.1.tgz#7205ce763efb17c2e41f26f121c420c6a7c2744f" + integrity sha512-bCtHMwL9LeDIozFn+oNhhFoq+yQ3BNdnsLSASUxLciOb1vgvpHsIO1dsENiGMgbb4SkP5TrzWzRiLddn8ahVOQ== + bigint-buffer@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/bigint-buffer/-/bigint-buffer-1.1.5.tgz#d038f31c8e4534c1f8d0015209bf34b4fa6dd442" From 74384fe89687916feb12fddd7dc4e1de67c8afec Mon Sep 17 00:00:00 2001 From: Jon Gurary Date: Wed, 4 Sep 2024 10:36:39 -0400 Subject: [PATCH 02/52] Add PyUSD bank (with emissions) to test --- Anchor.toml | 4 ++++ tests/03_addBank.spec.ts | 14 ++++++++++++++ tests/fixtures/pyusd_bank.json | 14 ++++++++++++++ 3 files changed, 32 insertions(+) create mode 100644 tests/fixtures/pyusd_bank.json diff --git a/Anchor.toml b/Anchor.toml index 8afc1f917..453e21d6d 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -44,6 +44,10 @@ filename = "tests/fixtures/bonk_bank.json" address = "4kNXetv8hSv9PzvzPZzEs1CTH6ARRRi2b8h6jk1ad1nP" filename = "tests/fixtures/cloud_bank.json" +[[test.validator.account]] +address = "Fe5QkKPVAh629UPP5aJ8sDZu8HTfe6M26jDQkKyXVhoA" +filename = "tests/fixtures/pyusd_bank.json" + [[test.validator.account]] address = "8FRFC6MoGGkMFQwngccyu69VnYbzykGeez7ignHVAFSN" filename = "tests/fixtures/localnet_usdc.json" diff --git a/tests/03_addBank.spec.ts b/tests/03_addBank.spec.ts index d5597b6b0..7f176cc9b 100644 --- a/tests/03_addBank.spec.ts +++ b/tests/03_addBank.spec.ts @@ -289,5 +289,19 @@ describe("Lending pool add bank (add bank to group)", () => { assert.deepEqual(cloudConfig.riskTier, { isolated: {} }); assertBNEqual(cloudConfig.totalAssetValueInitLimit, 0); assert.equal(cloudConfig.oracleMaxAge, 60); + + let pyUsdcBankKey = new PublicKey( + "Fe5QkKPVAh629UPP5aJ8sDZu8HTfe6M26jDQkKyXVhoA" + ); + let pyUsdcBankData = ( + await program.provider.connection.getAccountInfo(pyUsdcBankKey) + ).data.subarray(8); + printBufferGroups(pyUsdcBankData, 16, 896); + + const pb = await program.account.bank.fetch(pyUsdcBankKey); + assertKeysEqual( + pb.emissionsMint, + new PublicKey("2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo") + ); }); }); diff --git a/tests/fixtures/pyusd_bank.json b/tests/fixtures/pyusd_bank.json new file mode 100644 index 000000000..e59459d4d --- /dev/null +++ b/tests/fixtures/pyusd_bank.json @@ -0,0 +1,14 @@ +{ + "pubkey": "Fe5QkKPVAh629UPP5aJ8sDZu8HTfe6M26jDQkKyXVhoA", + "account": { + "lamports": 13864320, + "data": [ + "jjGm8jJCYbwXkkg7bIoqh7dHHYFPlZH5OVyECpzj2fTVun06S4p0ngbS7qNW7Dx5ehg9nAnFDP63jomJRxUTYMZvKp+sEMXJ9AAAAAAAAABqo0cIVQABAAAAAAAAAAAAerjNbJYAAQAAAAAAAAAAAD+qZNIhko6I815Qcr1imt+4Mav6RP0FS+3wO1rdzHb2//9rJOLCuVHJSdCU2JfYrAEawvoNUZ3rL2c5ocvqnISYjvz/AAAAAJVXzdUKawYAAAAAAAAAAAA78ZdGK1AwaMp/IAX8Hd174GpPOkna9ir36HsJ3+vLJf7+AAAAAAAAiYrUtxDQkwAAAAAAAAAAAKHCFhvpYRpXBQAAAAAAAADyQbEwcR8DqSUAAAAAAAAAujPXZgAAAAAAAIBmZmYAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAQAEAAAAAAAAAAACamZmZmRkBAAAAAAAAAAAAAID0IOa1AACamZmZmdkAAAAAAAAAAAAAmpmZmZkZAAAAAAAAAAAAAAAAAAAAAAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMP1KFyPAgAAAAAAAAAAAADNzMzMzAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABA8HaG3PX8B593VSzdmz3/NZEOVrRT3CqcG7FOExZ52aSAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgPQg5rUAAAAAAAAAAAAAAAAAAAAAAAAsAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAADoAwAAAAAAALimWlHsykcAAAAAAAAAAAAXkkg7bIoqh7dHHYFPlZH5OVyECpzj2fTVun06S4p0ngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "base64" + ], + "owner": "stag8sTKds2h4KzjUw3zKTsxbqvT4XKHdaR9X9E6Rct", + "executable": false, + "rentEpoch": 18446744073709551615, + "space": 1864 + } +} \ No newline at end of file From 4065aeacbfaea7c2c8cae8ce0b93b04c9e5996df Mon Sep 17 00:00:00 2001 From: Jon Gurary Date: Fri, 6 Sep 2024 16:04:08 -0400 Subject: [PATCH 03/52] Boilerplate for staking collatizer program --- Anchor.toml | 9 ++-- Cargo.lock | 10 ++++ programs/staking-collatizer/Cargo.toml | 31 +++++++++++ programs/staking-collatizer/Xargo.toml | 2 + programs/staking-collatizer/src/errors.rs | 7 +++ .../src/instructions/init_user.rs | 14 +++++ .../src/instructions/mod.rs | 3 ++ programs/staking-collatizer/src/lib.rs | 22 ++++++++ programs/staking-collatizer/src/macros.rs | 0 programs/staking-collatizer/src/state/mod.rs | 3 ++ .../src/state/stake_user.rs | 11 ++++ tests/rootHooks.ts | 8 ++- tests/s01_usersStake.spec.ts | 26 +++++++++ tests/utils/mocks.ts | 54 +++++++++++++------ 14 files changed, 180 insertions(+), 20 deletions(-) create mode 100644 programs/staking-collatizer/Cargo.toml create mode 100644 programs/staking-collatizer/Xargo.toml create mode 100644 programs/staking-collatizer/src/errors.rs create mode 100644 programs/staking-collatizer/src/instructions/init_user.rs create mode 100644 programs/staking-collatizer/src/instructions/mod.rs create mode 100644 programs/staking-collatizer/src/lib.rs create mode 100644 programs/staking-collatizer/src/macros.rs create mode 100644 programs/staking-collatizer/src/state/mod.rs create mode 100644 programs/staking-collatizer/src/state/stake_user.rs create mode 100644 tests/s01_usersStake.spec.ts diff --git a/Anchor.toml b/Anchor.toml index 8afc1f917..4934c76ea 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -10,6 +10,7 @@ skip-lint = false liquidity_incentive_program = "Lip1111111111111111111111111111111111111111" marginfi = "2jGhuVUuy3umdzByFx8sNWUAaf5vaeuDm78RDPEnhrMr" mocks = "5XaaR94jBubdbrRrNW7DtRvZeWvLhSHkEGU3jHTEXV3C" +staking-collatizer = "65e81uBnLPtUNaFbgzeU4gMwmCbMeeh6GCLDhEVaNNon" [programs.mainnet] liquidity_incentive_program = "LipsxuAkFkwa4RKNzn51wAsW7Dedzt1RNHMkTkDEZUW" @@ -19,12 +20,14 @@ marginfi = "MFv2hWf31Z9kbCa1snEPYctwafyhdvnV7FZnsebVacA" url = "https://api.apr.dev" [provider] -cluster = "localnet" -# cluster = "https://devnet.rpcpool.com/" +cluster = "Localnet" wallet = "~/.config/solana/id.json" [scripts] -test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/*.spec.ts --exit --require tests/rootHooks.ts" +# test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/*.spec.ts --exit --require tests/rootHooks.ts" + +# Staking Collatizer only +test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/s*.spec.ts --exit --require tests/rootHooks.ts" [test] startup_wait = 5000 diff --git a/Cargo.lock b/Cargo.lock index 0eaf5addf..4551f12c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6516,6 +6516,16 @@ dependencies = [ "spl-program-error 0.4.1", ] +[[package]] +name = "staking-collatizer" +version = "0.1.0" +dependencies = [ + "anchor-lang 0.30.1", + "anchor-spl 0.30.1", + "bytemuck", + "solana-program", +] + [[package]] name = "static_assertions" version = "1.1.0" diff --git a/programs/staking-collatizer/Cargo.toml b/programs/staking-collatizer/Cargo.toml new file mode 100644 index 000000000..2f99f12bd --- /dev/null +++ b/programs/staking-collatizer/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "staking-collatizer" +version = "0.1.0" +description = "Manages control of staked SOL assets to use them as collateral in other programs" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "staking_collatizer" + +[features] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +cpi = ["no-entrypoint"] +default = ["mainnet-beta"] +idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"] +test-bpf = ["test", "debug"] +test = [] +client = [] +devnet = [] +mainnet-beta = [] +debug = [] +staging = [] + +[dependencies] +solana-program = { workspace = true } +anchor-lang = { workspace = true } +anchor-spl = { workspace = true } + +bytemuck = "1.9.1" diff --git a/programs/staking-collatizer/Xargo.toml b/programs/staking-collatizer/Xargo.toml new file mode 100644 index 000000000..475fb71ed --- /dev/null +++ b/programs/staking-collatizer/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] diff --git a/programs/staking-collatizer/src/errors.rs b/programs/staking-collatizer/src/errors.rs new file mode 100644 index 000000000..7c47ba44e --- /dev/null +++ b/programs/staking-collatizer/src/errors.rs @@ -0,0 +1,7 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum ErrorCode { + #[msg("Math error")] // 6000 + MathError, +} \ No newline at end of file diff --git a/programs/staking-collatizer/src/instructions/init_user.rs b/programs/staking-collatizer/src/instructions/init_user.rs new file mode 100644 index 000000000..478caed86 --- /dev/null +++ b/programs/staking-collatizer/src/instructions/init_user.rs @@ -0,0 +1,14 @@ +use anchor_lang::prelude::*; + +#[derive(Accounts)] +pub struct InitUser<'info> { + pub payer: Signer<'info>, +} + +pub fn init_user(ctx: Context) -> Result<()> { + msg!( + "Nothing was done. Signed by: {:?}", + ctx.accounts.payer.key() + ); + Ok(()) +} diff --git a/programs/staking-collatizer/src/instructions/mod.rs b/programs/staking-collatizer/src/instructions/mod.rs new file mode 100644 index 000000000..873fc8930 --- /dev/null +++ b/programs/staking-collatizer/src/instructions/mod.rs @@ -0,0 +1,3 @@ +pub mod init_user; + +pub use init_user::*; diff --git a/programs/staking-collatizer/src/lib.rs b/programs/staking-collatizer/src/lib.rs new file mode 100644 index 000000000..a33d0e222 --- /dev/null +++ b/programs/staking-collatizer/src/lib.rs @@ -0,0 +1,22 @@ +use anchor_lang::prelude::*; + +declare_id!("65e81uBnLPtUNaFbgzeU4gMwmCbMeeh6GCLDhEVaNNon"); + +pub mod errors; +pub mod instructions; +pub mod macros; +pub mod state; +// pub mod utils; + +use crate::instructions::*; +// use crate::state::*; +// use errors::*; + +#[program] +pub mod staking_collatizer { + use super::*; + + pub fn init_user(ctx: Context) -> Result<()> { + instructions::init_user::init_user(ctx) + } +} diff --git a/programs/staking-collatizer/src/macros.rs b/programs/staking-collatizer/src/macros.rs new file mode 100644 index 000000000..e69de29bb diff --git a/programs/staking-collatizer/src/state/mod.rs b/programs/staking-collatizer/src/state/mod.rs new file mode 100644 index 000000000..6c381b99b --- /dev/null +++ b/programs/staking-collatizer/src/state/mod.rs @@ -0,0 +1,3 @@ +pub mod stake_user; + +pub use stake_user::*; diff --git a/programs/staking-collatizer/src/state/stake_user.rs b/programs/staking-collatizer/src/state/stake_user.rs new file mode 100644 index 000000000..089f18f9c --- /dev/null +++ b/programs/staking-collatizer/src/state/stake_user.rs @@ -0,0 +1,11 @@ +use anchor_lang::prelude::*; + +#[account()] +pub struct StakeUser { + /// The account's own key + pub key: Pubkey, +} + +impl StakeUser { + pub const LEN: usize = std::mem::size_of::(); +} diff --git a/tests/rootHooks.ts b/tests/rootHooks.ts index bf72966a7..82d4ff5af 100644 --- a/tests/rootHooks.ts +++ b/tests/rootHooks.ts @@ -12,6 +12,7 @@ import { import { Marginfi } from "../target/types/marginfi"; import { Keypair, Transaction } from "@solana/web3.js"; import { setupPythOracles } from "./utils/pyth_mocks"; +import { StakingCollatizer } from "../target/types/staking_collatizer"; export const ecosystem: Ecosystem = getGenericEcosystem(); export let oracles: Oracles = undefined; @@ -31,7 +32,9 @@ export const bankKeypairA = Keypair.generate(); export const mochaHooks = { beforeAll: async () => { - const program = workspace.Marginfi as Program; + const mrgnProgram = workspace.Marginfi as Program; + const collatProgram = + workspace.StakingCollatizer as Program; const provider = AnchorProvider.local(); const wallet = provider.wallet as Wallet; @@ -73,7 +76,8 @@ export const mochaHooks = { await provider.sendAndConfirm(tx, [usdcMint, aMint, bMint]); const setupUserOptions: SetupTestUserOptions = { - marginProgram: program, + marginProgram: mrgnProgram, + collatizerProgram: collatProgram, forceWallet: undefined, // If mints are created, typically create the ATA too, otherwise pass undefined... wsolMint: undefined, diff --git a/tests/s01_usersStake.spec.ts b/tests/s01_usersStake.spec.ts new file mode 100644 index 000000000..104a48ac3 --- /dev/null +++ b/tests/s01_usersStake.spec.ts @@ -0,0 +1,26 @@ +import { Program, workspace } from "@coral-xyz/anchor"; +import { Transaction } from "@solana/web3.js"; +import { groupInitialize } from "./utils/instructions"; +import { Marginfi } from "../target/types/marginfi"; +import { groupAdmin, marginfiGroup, users } from "./rootHooks"; +import { assertKeysEqual } from "./utils/genericTests"; +import { StakingCollatizer } from "../target/types/staking_collatizer"; + +describe("User stakes some native and creates an account", () => { + const program = workspace.StakingCollatizer as Program; + + it("(admin) Init user account - happy path", async () => { + let tx = new Transaction(); + + tx.add( + await program.methods + .initUser() + .accounts({ + payer: users[0].wallet.publicKey, + }) + .instruction() + ); + + await users[0].userCollatizerProgram.provider.sendAndConfirm(tx); + }); +}); diff --git a/tests/utils/mocks.ts b/tests/utils/mocks.ts index 69a139814..7d0a2f96f 100644 --- a/tests/utils/mocks.ts +++ b/tests/utils/mocks.ts @@ -16,6 +16,7 @@ import { } from "@solana/web3.js"; import { Marginfi } from "../../target/types/marginfi"; import { Mocks } from "../../target/types/mocks"; +import { StakingCollatizer } from "../../target/types/staking_collatizer"; export type Ecosystem = { /** A generic wsol mint with 9 decimals (same as native) */ @@ -95,8 +96,10 @@ export type mockUser = { tokenBAccount: PublicKey; /** Users's ATA for USDC */ usdcAccount: PublicKey; - /** A program that uses the user's wallet */ + /** A marginfi program that uses the user's wallet */ userMarginProgram: Program | undefined; + /** A staking collatizer program that uses the user's wallet */ + userCollatizerProgram: Program | undefined; }; /** @@ -104,6 +107,7 @@ export type mockUser = { */ export interface SetupTestUserOptions { marginProgram: Program; + collatizerProgram: Program; /** Force the mock user to use this keypair */ forceWallet: Keypair; wsolMint: PublicKey; @@ -210,6 +214,9 @@ export const setupTestUser = async ( userMarginProgram: options.marginProgram ? getUserMarginfiProgram(options.marginProgram, userWalletKeypair) : undefined, + userCollatizerProgram: options.marginProgram + ? getUserCollatizerProgram(options.collatizerProgram, userWalletKeypair) + : undefined, }; return user; }; @@ -231,6 +238,23 @@ export const getUserMarginfiProgram = ( return userProgram; }; +/** + * Generates a mock program that can sign transactions as the user's wallet + * @param program + * @param userWallet + * @returns + */ +export const getUserCollatizerProgram = ( + program: Program, + userWallet: Keypair | Wallet +) => { + const wallet = + userWallet instanceof Keypair ? new Wallet(userWallet) : userWallet; + const provider = new AnchorProvider(program.provider.connection, wallet, {}); + const userProgram = new Program(program.idl, provider); + return userProgram; +}; + /** * Ixes to create a mint, the payer gains the Mint Tokens/Freeze authority * @param payer - pays account init fees, must sign, gains mint/freeze authority @@ -276,19 +300,19 @@ export const createSimpleMint = async ( }; export type Oracles = { - wsolOracle: Keypair, - wsolPrice: number, - wsolDecimals: number, - usdcOracle: Keypair, - usdcPrice: number, - usdcDecimals: number, - tokenAOracle: Keypair, - tokenAPrice: number, - tokenADecimals: number, - tokenBOracle: Keypair, - tokenBPrice: number, - tokenBDecimals:number, -} + wsolOracle: Keypair; + wsolPrice: number; + wsolDecimals: number; + usdcOracle: Keypair; + usdcPrice: number; + usdcDecimals: number; + tokenAOracle: Keypair; + tokenAPrice: number; + tokenADecimals: number; + tokenBOracle: Keypair; + tokenBPrice: number; + tokenBDecimals: number; +}; /** * Creates an account to store data arbitrary data. @@ -344,4 +368,4 @@ export const storeMockAccount = async ( .instruction() ); await program.provider.sendAndConfirm(tx, [wallet.payer, account]); -}; \ No newline at end of file +}; From 9b4f5aae88dcd8e1a6ea200e83c17b1ca224b0a3 Mon Sep 17 00:00:00 2001 From: Jon Gurary Date: Fri, 6 Sep 2024 22:56:43 -0400 Subject: [PATCH 04/52] WIP decoding stake structs manually --- Anchor.toml | 2 +- package.json | 2 + tests/rootHooks.ts | 97 +++++- tests/s01_usersStake.spec.ts | 204 ++++++++++++- tests/utils/mocks.ts | 7 + tests/utils/stake-utils.ts | 560 +++++++++++++++++++++++++++++++++++ tests/utils/types.ts | 2 + yarn.lock | 44 +++ 8 files changed, 896 insertions(+), 22 deletions(-) create mode 100644 tests/utils/stake-utils.ts diff --git a/Anchor.toml b/Anchor.toml index 4934c76ea..92c3dcc51 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -10,7 +10,7 @@ skip-lint = false liquidity_incentive_program = "Lip1111111111111111111111111111111111111111" marginfi = "2jGhuVUuy3umdzByFx8sNWUAaf5vaeuDm78RDPEnhrMr" mocks = "5XaaR94jBubdbrRrNW7DtRvZeWvLhSHkEGU3jHTEXV3C" -staking-collatizer = "65e81uBnLPtUNaFbgzeU4gMwmCbMeeh6GCLDhEVaNNon" +staking_collatizer = "65e81uBnLPtUNaFbgzeU4gMwmCbMeeh6GCLDhEVaNNon" [programs.mainnet] liquidity_incentive_program = "LipsxuAkFkwa4RKNzn51wAsW7Dedzt1RNHMkTkDEZUW" diff --git a/package.json b/package.json index fdb1184eb..9db8b8ceb 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "bignumber.js": "^9.1.2" }, "devDependencies": { + "anchor-bankrun": "^0.4.0", + "solana-bankrun": "^0.3.0", "@types/bn.js": "^5.1.0", "@types/chai": "^4.3.0", "@types/mocha": "^9.0.0", diff --git a/tests/rootHooks.ts b/tests/rootHooks.ts index 82d4ff5af..860b5f4d2 100644 --- a/tests/rootHooks.ts +++ b/tests/rootHooks.ts @@ -4,13 +4,21 @@ import { echoEcosystemInfo, Ecosystem, getGenericEcosystem, - mockUser, + mockUser as MockUser, Oracles, setupTestUser, SetupTestUserOptions, + Validator, } from "./utils/mocks"; import { Marginfi } from "../target/types/marginfi"; -import { Keypair, Transaction } from "@solana/web3.js"; +import { + Keypair, + PublicKey, + SystemProgram, + Transaction, + VoteInit, + VoteProgram, +} from "@solana/web3.js"; import { setupPythOracles } from "./utils/pyth_mocks"; import { StakingCollatizer } from "../target/types/staking_collatizer"; @@ -18,11 +26,16 @@ export const ecosystem: Ecosystem = getGenericEcosystem(); export let oracles: Oracles = undefined; export const verbose = true; /** The program owner is also the provider wallet */ -export let globalProgramAdmin: mockUser = undefined; -export let groupAdmin: mockUser = undefined; -export const users: mockUser[] = []; +export let globalProgramAdmin: MockUser = undefined; +export let groupAdmin: MockUser = undefined; +/** Administers valiator votes and withdraws */ +export let validatorAdmin: MockUser = undefined; +export const users: MockUser[] = []; export const numUsers = 2; +export const validators: Validator[] = []; +export const numValidators = 2; + /** Group used for all happy-path tests */ export const marginfiGroup = Keypair.generate(); /** Bank for USDC */ @@ -87,6 +100,11 @@ export const mochaHooks = { }; groupAdmin = await setupTestUser(provider, wallet.payer, setupUserOptions); + validatorAdmin = await setupTestUser( + provider, + wallet.payer, + setupUserOptions + ); for (let i = 0; i < numUsers; i++) { const user = await setupTestUser( @@ -117,5 +135,74 @@ export const mochaHooks = { ecosystem.tokenBDecimals, verbose ); + + for (let i = 0; i < numValidators; i++) { + const validator = await createValidator( + provider, + validatorAdmin.wallet, + validatorAdmin.wallet.publicKey + ); + if (verbose) { + console.log("Validator vote acc [" + i + "]: " + validator.voteAccount); + } + validators.push(validator); + } + if (verbose) { + console.log("---End ecosystem setup---"); + console.log(""); + } }, }; + +/** + * Create a mock validator with given vote/withdraw authority + * + * @param provider + * @param authorizedVoter - also pays init fees + * @param authorizedWithdrawer - also pays init fees + * @param comission - defaults to 0 + */ +export const createValidator = async ( + provider: AnchorProvider, + authorizedVoter: Keypair, + authorizedWithdrawer: PublicKey, + commission: number = 0 // Commission rate from 0 to 100 +) => { + const voteAccount = Keypair.generate(); + const node = Keypair.generate(); + + const tx = new Transaction().add( + // Create the vote account + SystemProgram.createAccount({ + fromPubkey: authorizedVoter.publicKey, + newAccountPubkey: voteAccount.publicKey, + lamports: await provider.connection.getMinimumBalanceForRentExemption( + VoteProgram.space + ), + space: VoteProgram.space, + programId: VoteProgram.programId, + }), + // Initialize the vote account + VoteProgram.initializeAccount({ + votePubkey: voteAccount.publicKey, + nodePubkey: node.publicKey, + voteInit: new VoteInit( + node.publicKey, + authorizedVoter.publicKey, + authorizedWithdrawer, + commission + ), + }) + ); + + await provider.sendAndConfirm(tx, [voteAccount, authorizedVoter, node]); + + const validator: Validator = { + node: node.publicKey, + authorizedVoter: authorizedVoter.publicKey, + authorizedWithdrawer: authorizedWithdrawer, + voteAccount: voteAccount.publicKey, + }; + + return validator; +}; diff --git a/tests/s01_usersStake.spec.ts b/tests/s01_usersStake.spec.ts index 104a48ac3..58810cc53 100644 --- a/tests/s01_usersStake.spec.ts +++ b/tests/s01_usersStake.spec.ts @@ -1,26 +1,198 @@ -import { Program, workspace } from "@coral-xyz/anchor"; -import { Transaction } from "@solana/web3.js"; -import { groupInitialize } from "./utils/instructions"; -import { Marginfi } from "../target/types/marginfi"; -import { groupAdmin, marginfiGroup, users } from "./rootHooks"; -import { assertKeysEqual } from "./utils/genericTests"; +import { + AnchorProvider, + BN, + getProvider, + Program, + Wallet, + workspace, +} from "@coral-xyz/anchor"; +import { + Connection, + LAMPORTS_PER_SOL, + PublicKey, + SYSVAR_CLOCK_PUBKEY, + Transaction, +} from "@solana/web3.js"; +import { users, validators, verbose } from "./rootHooks"; import { StakingCollatizer } from "../target/types/staking_collatizer"; +import { + createStakeAccount, + delegateStake, + getStakeAccount, + getStakeActivation, +} from "./utils/stake-utils"; +import { assertBNEqual, assertKeysEqual } from "./utils/genericTests"; +import { u64MAX_BN } from "./utils/types"; + +import path from "path"; +import { BankrunProvider } from "anchor-bankrun"; +import type { ProgramTestContext } from "solana-bankrun"; +import { Clock, startAnchor } from "solana-bankrun"; describe("User stakes some native and creates an account", () => { const program = workspace.StakingCollatizer as Program; + const provider = getProvider() as AnchorProvider; + const wallet = provider.wallet as Wallet; + + let bankrunContext: ProgramTestContext; + let bankRunProvider: BankrunProvider; + let bankrunProgram: Program; - it("(admin) Init user account - happy path", async () => { - let tx = new Transaction(); + let stakeAccount: PublicKey; + + it("(user 0) Create user stake account and stake to validator", async () => { + stakeAccount = await createStakeAccount( + users[0], + provider, + 10 * LAMPORTS_PER_SOL + ); - tx.add( - await program.methods - .initUser() - .accounts({ - payer: users[0].wallet.publicKey, - }) - .instruction() + await delegateStake( + users[0], + provider, + stakeAccount, + validators[0].voteAccount, + verbose, + "user 0" ); - await users[0].userCollatizerProgram.provider.sendAndConfirm(tx); + const epochBefore = (await provider.connection.getEpochInfo()).epoch; + const stakeAccountInfo = await provider.connection.getAccountInfo( + stakeAccount + ); + const stakeAccBefore = getStakeAccount(stakeAccountInfo.data); + const meta = stakeAccBefore.meta; + const delegation = stakeAccBefore.stake.delegation; + const rent = new BN(meta.rentExemptReserve.toString()); + + assertKeysEqual(delegation.voterPubkey, validators[0].voteAccount); + assertBNEqual( + new BN(delegation.stake.toString()), + new BN(10 * LAMPORTS_PER_SOL).sub(rent) + ); + assertBNEqual(new BN(delegation.activationEpoch.toString()), epochBefore); + assertBNEqual(new BN(delegation.deactivationEpoch.toString()), u64MAX_BN); + + const stakeStatusBefore = await getStakeActivation( + provider.connection, + stakeAccount + ); + if (verbose) { + console.log("It is now epoch: " + epochBefore); + console.log( + "Stake active: " + + stakeStatusBefore.active.toLocaleString() + + " inactive " + + stakeStatusBefore.inactive.toLocaleString() + + " status: " + + stakeStatusBefore.status + ); + } }); + + it("Advance the epoch", async () => { + // Load the necessary accounts and add them to the bankrun context + const accountKeys = [ + stakeAccount, + users[0].wallet.publicKey, + validators[0].voteAccount, + ]; + const accounts = await program.provider.connection.getMultipleAccountsInfo( + accountKeys + ); + const addedAccounts = accountKeys.map((address, index) => ({ + address, + info: accounts[index], + })); + + bankrunContext = await startAnchor(path.resolve(), [], addedAccounts); + bankRunProvider = new BankrunProvider(bankrunContext); + bankrunProgram = new Program(program.idl, provider); + const client = bankrunContext.banksClient; + + bankrunContext.warpToEpoch(1n); + + let clock = await client.getAccount(SYSVAR_CLOCK_PUBKEY); + // epoch is bytes 16-24 + let epoch = new BN(clock.data.slice(16, 24), 10, "le").toNumber(); + if (verbose) { + console.log("Warped to epoch: " + epoch); + } + + const stakeStatusAfter1 = await getStakeActivation( + bankRunProvider.connection, + stakeAccount, + epoch + ); + if (verbose) { + console.log("It is now epoch: " + epoch); + console.log( + "Stake active: " + + stakeStatusAfter1.active.toLocaleString() + + " inactive " + + stakeStatusAfter1.inactive.toLocaleString() + + " status: " + + stakeStatusAfter1.status + ); + } + + bankrunContext.warpToEpoch(2n); + + clock = await client.getAccount(SYSVAR_CLOCK_PUBKEY); + // epoch is bytes 16-24 + epoch = new BN(clock.data.slice(16, 24), 10, "le").toNumber(); + if (verbose) { + console.log("Warped to epoch: " + epoch); + } + + const stakeStatusAfter2 = await getStakeActivation( + bankRunProvider.connection, + stakeAccount, + epoch + ); + if (verbose) { + console.log("It is now epoch: " + epoch); + console.log( + "Stake active: " + + stakeStatusAfter2.active.toLocaleString() + + " inactive " + + stakeStatusAfter2.inactive.toLocaleString() + + " status: " + + stakeStatusAfter2.status + ); + } + }); + + // it("(user 0) Init user account - happy path", async () => { + // // TODO the stake program must be rewritten to use the bankrun provider... + // const epoch = (await provider.connection.getEpochInfo()).epoch; + // const stakeStatusAfter = await getStakeActivation( + // provider.connection, + // stakeAccount + // ); + // if (verbose) { + // console.log("It is now epoch: " + epoch); + // console.log( + // "Stake active: " + + // stakeStatusAfter.active.toLocaleString() + + // " inactive " + + // stakeStatusAfter.inactive.toLocaleString() + + // " status: " + + // stakeStatusAfter.status + // ); + // } + + // let tx = new Transaction(); + + // tx.add( + // await program.methods + // .initUser() + // .accounts({ + // payer: users[0].wallet.publicKey, + // }) + // .instruction() + // ); + + // await users[0].userCollatizerProgram.provider.sendAndConfirm(tx); + // }); }); diff --git a/tests/utils/mocks.ts b/tests/utils/mocks.ts index 7d0a2f96f..e83148cd0 100644 --- a/tests/utils/mocks.ts +++ b/tests/utils/mocks.ts @@ -369,3 +369,10 @@ export const storeMockAccount = async ( ); await program.provider.sendAndConfirm(tx, [wallet.payer, account]); }; + +export type Validator = { + node: PublicKey; + authorizedVoter: PublicKey; + authorizedWithdrawer: PublicKey; + voteAccount: PublicKey; +} \ No newline at end of file diff --git a/tests/utils/stake-utils.ts b/tests/utils/stake-utils.ts new file mode 100644 index 000000000..1dc381a69 --- /dev/null +++ b/tests/utils/stake-utils.ts @@ -0,0 +1,560 @@ +import { AnchorProvider } from "@coral-xyz/anchor"; +import { + Keypair, + Transaction, + SystemProgram, + StakeProgram, + PublicKey, + LAMPORTS_PER_SOL, + AccountInfo, + ParsedAccountData, + RpcResponseAndContext, + Connection, +} from "@solana/web3.js"; +import { mockUser } from "./mocks"; + +/** + * Create a stake account for some user + * @param user + * @param provider + * @param amount - in SOL (lamports), in native decimals + * @param verbose + * @returns + */ +export const createStakeAccount = async ( + user: mockUser, + provider: AnchorProvider, + amount: number, + verbose: boolean = true +) => { + const stakeAccount = Keypair.generate(); + const userPublicKey = user.wallet.publicKey; + + // Create a stake account and fund it with the specified amount of SOL + const tx = new Transaction().add( + SystemProgram.createAccount({ + fromPubkey: userPublicKey, + newAccountPubkey: stakeAccount.publicKey, + lamports: amount, + space: StakeProgram.space, // Space required for a stake account + programId: StakeProgram.programId, + }), + StakeProgram.initialize({ + stakePubkey: stakeAccount.publicKey, + authorized: { + staker: userPublicKey, + withdrawer: userPublicKey, + }, + }) + ); + + await provider.sendAndConfirm(tx, [user.wallet, stakeAccount]); + + if (verbose) { + console.log("Create stake account: " + stakeAccount.publicKey); + console.log(" Stake: " + amount / LAMPORTS_PER_SOL + " SOL"); + } + return stakeAccount.publicKey; +}; + +/** + * Delegate a stake account to a validator. + * @param user - wallet signs + * @param provider + * @param stakeAccount + * @param validatorVoteAccount + * @param verbose + */ +export const delegateStake = async ( + user: mockUser, + provider: AnchorProvider, + stakeAccount: PublicKey, + validatorVoteAccount: PublicKey, + verbose: boolean = true, + userDisplayName: string = "some user" +) => { + const tx = new Transaction().add( + StakeProgram.delegate({ + stakePubkey: stakeAccount, + authorizedPubkey: user.wallet.publicKey, + votePubkey: validatorVoteAccount, + }) + ); + + await provider.sendAndConfirm(tx, [user.wallet]); + + if (verbose) { + console.log(userDisplayName + " delegated to " + validatorVoteAccount); + } +}; + +/** + * Delegation information for a StakeAccount + * + * Copied from https://github.com/solana-developers/solana-rpc-get-stake-activation/blob/main/web3js-1.0/src/stake.ts + * */ +export type Delegation = { + voterPubkey: PublicKey; + stake: bigint; + activationEpoch: bigint; + deactivationEpoch: bigint; +}; + +/** + * Parsed content of an on-chain StakeAccount + * + * Copied from https://github.com/solana-developers/solana-rpc-get-stake-activation/blob/main/web3js-1.0/src/stake.ts + * */ +export type StakeAccount = { + discriminant: bigint; + meta: { + rentExemptReserve: bigint; + authorized: { + staker: PublicKey; + withdrawer: PublicKey; + }; + lockup: { + unixTimestamp: bigint; + epoch: bigint; + custodian: PublicKey; + }; + }; + stake: { + delegation: { + voterPubkey: PublicKey; + stake: bigint; + activationEpoch: bigint; + deactivationEpoch: bigint; + }; + creditsObserved: bigint; + }; +}; + +/** + * Decode a StakeAccount from parsed account data. + * + * Copied from https://github.com/solana-developers/solana-rpc-get-stake-activation/blob/main/web3js-1.0/src/stake.ts + * */ +export const getStakeAccount = function (data: Buffer): StakeAccount { + let offset = 0; + + // Discriminant (4 bytes) + const discriminant = data.readBigUInt64LE(offset); + offset += 4; + + // Meta + const rentExemptReserve = data.readBigUInt64LE(offset); + offset += 8; + + // Authorized staker and withdrawer (2 public keys) + const staker = new PublicKey(data.subarray(offset, offset + 32)); + offset += 32; + const withdrawer = new PublicKey(data.subarray(offset, offset + 32)); + offset += 32; + + // Lockup: unixTimestamp, epoch, custodian + const unixTimestamp = data.readBigUInt64LE(offset); + offset += 8; + const epoch = data.readBigUInt64LE(offset); + offset += 8; + const custodian = new PublicKey(data.subarray(offset, offset + 32)); + offset += 32; + + // Stake: Delegation + const voterPubkey = new PublicKey(data.subarray(offset, offset + 32)); + offset += 32; + const stake = data.readBigUInt64LE(offset); + offset += 8; + const activationEpoch = data.readBigUInt64LE(offset); + offset += 8; + const deactivationEpoch = data.readBigUInt64LE(offset); + offset += 8; + + // Credits observed + const creditsObserved = data.readBigUInt64LE(offset); + + // Return the parsed StakeAccount object + return { + discriminant, + meta: { + rentExemptReserve, + authorized: { + staker, + withdrawer, + }, + lockup: { + unixTimestamp, + epoch, + custodian, + }, + }, + stake: { + delegation: { + voterPubkey, + stake, + activationEpoch, + deactivationEpoch, + }, + creditsObserved, + }, + }; +}; + +/** + * Parsed content of an on-chain Stake History Entry + * + * Copied from https://github.com/solana-developers/solana-rpc-get-stake-activation/blob/main/web3js-1.0/src/stake.ts + * */ +export type StakeHistoryEntry = { + epoch: bigint; + effective: bigint; + activating: bigint; + deactivating: bigint; +}; + +/** + * Decode a StakeHistoryEntry from parsed account data. + * + * Copied from https://github.com/solana-developers/solana-rpc-get-stake-activation/blob/main/web3js-1.0/src/stake.ts + * and modified to directly read from buffer + * */ +export const getStakeHistory = function (data: Buffer): StakeHistoryEntry[] { + const stakeHistory: StakeHistoryEntry[] = []; + const entrySize = 24; // Each entry is 24 bytes (3 x 8-byte u64 fields) + + // TODO use account parsed and compare what the issue is.... + for ( + let offset = 0, epoch = 0n; + offset + entrySize <= data.length; + offset += entrySize, epoch++ + ) { + const effective = data.readBigUInt64LE(offset); // u64 effective + const activating = data.readBigUInt64LE(offset + 8); // u64 activating + const deactivating = data.readBigUInt64LE(offset + 16); // u64 deactivating + + if (epoch < 5) { + console.log( + "LOG " + + epoch + + ": e" + + effective + + " a" + + activating + + " d" + + deactivating + ); + } + stakeHistory.push({ + epoch, // Inferred from the position in the stake history + effective, + activating, + deactivating, + }); + } + + return stakeHistory; +}; + +/** + * Representation of on-chain stake + * + * Copied from https://github.com/solana-developers/solana-rpc-get-stake-activation/blob/main/web3js-1.0/src/delegation.ts + */ +export interface StakeActivatingAndDeactivating { + effective: bigint; + activating: bigint; + deactivating: bigint; +} + +/** + * Representation of on-chain stake excluding deactivating stake + * + * Copied from https://github.com/solana-developers/solana-rpc-get-stake-activation/blob/main/web3js-1.0/src/delegation.ts + */ +export interface EffectiveAndActivating { + effective: bigint; + activating: bigint; +} + +/** + * Get stake histories for a given epoch + * + * Copied from https://github.com/solana-developers/solana-rpc-get-stake-activation/blob/main/web3js-1.0/src/delegation.ts + */ +function getStakeHistoryEntry( + epoch: bigint, + stakeHistory: StakeHistoryEntry[] +): StakeHistoryEntry | null { + for (const entry of stakeHistory) { + console.log( + "epoch read: " + + entry.epoch + + " " + + entry.activating + + " " + + entry.effective + ); + if (entry.epoch === epoch) { + console.log( + "epoch found: " + + entry.epoch + + " " + + entry.activating + + " " + + entry.effective + ); + return entry; + } + } + return null; +} + +const WARMUP_COOLDOWN_RATE = 0.09; + +/** + * Get on-chain status of activating stake + * + * Copied from https://github.com/solana-developers/solana-rpc-get-stake-activation/blob/main/web3js-1.0/src/delegation.ts + */ +export function getStakeAndActivating( + delegation: Delegation, + targetEpoch: bigint, + stakeHistory: StakeHistoryEntry[] +): EffectiveAndActivating { + if (delegation.activationEpoch === delegation.deactivationEpoch) { + // activated but instantly deactivated; no stake at all regardless of target_epoch + return { + effective: BigInt(0), + activating: BigInt(0), + }; + } else if (targetEpoch === delegation.activationEpoch) { + // all is activating + return { + effective: BigInt(0), + activating: delegation.stake, + }; + } else if (targetEpoch < delegation.activationEpoch) { + // not yet enabled + return { + effective: BigInt(0), + activating: BigInt(0), + }; + } + + let currentEpoch = delegation.activationEpoch; + console.log("current: " + currentEpoch); + let entry = getStakeHistoryEntry(currentEpoch, stakeHistory); + console.log("entry: " + entry.activating + " " + entry.deactivating); + if (entry !== null) { + // target_epoch > self.activation_epoch + + // loop from my activation epoch until the target epoch summing up my entitlement + // current effective stake is updated using its previous epoch's cluster stake + let currentEffectiveStake = BigInt(0); + while (entry !== null) { + currentEpoch++; + const remaining = delegation.stake - currentEffectiveStake; + const weight = Number(remaining) / Number(entry.activating); + console.log(weight); + const newlyEffectiveClusterStake = + Number(entry.effective) * WARMUP_COOLDOWN_RATE; + console.log(newlyEffectiveClusterStake); + const newlyEffectiveStake = BigInt( + Math.max(1, Math.round(weight * newlyEffectiveClusterStake)) + ); + + currentEffectiveStake += newlyEffectiveStake; + if (currentEffectiveStake >= delegation.stake) { + currentEffectiveStake = delegation.stake; + break; + } + + if ( + currentEpoch >= targetEpoch || + currentEpoch >= delegation.deactivationEpoch + ) { + break; + } + entry = getStakeHistoryEntry(currentEpoch, stakeHistory); + } + return { + effective: currentEffectiveStake, + activating: delegation.stake - currentEffectiveStake, + }; + } else { + // no history or I've dropped out of history, so assume fully effective + return { + effective: delegation.stake, + activating: BigInt(0), + }; + } +} + +/** + * Get on-chain status of activating and deactivating stake + * + * Copied from https://github.com/solana-developers/solana-rpc-get-stake-activation/blob/main/web3js-1.0/src/delegation.ts + */ +export function getStakeActivatingAndDeactivating( + delegation: Delegation, + targetEpoch: bigint, + stakeHistory: StakeHistoryEntry[] +): StakeActivatingAndDeactivating { + const { effective, activating } = getStakeAndActivating( + delegation, + targetEpoch, + stakeHistory + ); + + // then de-activate some portion if necessary + if (targetEpoch < delegation.deactivationEpoch) { + return { + effective, + activating, + deactivating: BigInt(0), + }; + } else if (targetEpoch == delegation.deactivationEpoch) { + // can only deactivate what's activated + return { + effective, + activating: BigInt(0), + deactivating: effective, + }; + } + let currentEpoch = delegation.deactivationEpoch; + let entry = getStakeHistoryEntry(currentEpoch, stakeHistory); + if (entry !== null) { + // target_epoch > self.activation_epoch + // loop from my deactivation epoch until the target epoch + // current effective stake is updated using its previous epoch's cluster stake + let currentEffectiveStake = effective; + while (entry !== null) { + currentEpoch++; + // if there is no deactivating stake at prev epoch, we should have been + // fully undelegated at this moment + if (entry.deactivating === BigInt(0)) { + break; + } + + // I'm trying to get to zero, how much of the deactivation in stake + // this account is entitled to take + const weight = Number(currentEffectiveStake) / Number(entry.deactivating); + + // portion of newly not-effective cluster stake I'm entitled to at current epoch + const newlyNotEffectiveClusterStake = + Number(entry.effective) * WARMUP_COOLDOWN_RATE; + const newlyNotEffectiveStake = BigInt( + Math.max(1, Math.round(weight * newlyNotEffectiveClusterStake)) + ); + + currentEffectiveStake -= newlyNotEffectiveStake; + if (currentEffectiveStake <= 0) { + currentEffectiveStake = BigInt(0); + break; + } + + if (currentEpoch >= targetEpoch) { + break; + } + entry = getStakeHistoryEntry(currentEpoch, stakeHistory); + } + + // deactivating stake should equal to all of currently remaining effective stake + return { + effective: currentEffectiveStake, + deactivating: currentEffectiveStake, + activating: BigInt(0), + }; + } else { + return { + effective: BigInt(0), + activating: BigInt(0), + deactivating: BigInt(0), + }; + } +} + +/** + * Representation of on-chain stake + * + * Copied from https://github.com/solana-developers/solana-rpc-get-stake-activation/blob/main/web3js-1.0/src/rpc.ts + */ +export interface StakeActivation { + status: string; + active: bigint; + inactive: bigint; +} + +/** + * Get on-chain stake status of a stake account (activating, inactive, etc) + * + * Copied from https://github.com/solana-developers/solana-rpc-get-stake-activation/blob/main/web3js-1.0/src/rpc.ts + */ +export async function getStakeActivation( + connection: Connection, + stakeAddress: PublicKey, + epoch: number | undefined = undefined // Added to bypass connection.getEpochInfo() when using a bankrun provider. +): Promise { + const SYSVAR_STAKE_HISTORY_ADDRESS = new PublicKey( + "SysvarStakeHistory1111111111111111111111111" + ); + const epochInfoPromise = + epoch !== undefined + ? Promise.resolve({ epoch }) + : connection.getEpochInfo(); + const [epochInfo, { stakeAccount, stakeAccountLamports }, stakeHistory] = + await Promise.all([ + epochInfoPromise, + (async () => { + const stakeAccountInfo = await connection.getAccountInfo(stakeAddress); + if (stakeAccountInfo === null) { + throw new Error("Account not found"); + } + const stakeAccount = getStakeAccount(stakeAccountInfo.data); + const stakeAccountLamports = stakeAccountInfo.lamports; + return { stakeAccount, stakeAccountLamports }; + })(), + (async () => { + const stakeHistoryInfo = await connection.getAccountInfo( + SYSVAR_STAKE_HISTORY_ADDRESS + ); + if (stakeHistoryInfo === null) { + throw new Error("StakeHistory not found"); + } + return getStakeHistory(stakeHistoryInfo.data); + })(), + ]); + let sh = stakeHistory[0]; + console.log("EPOCH 0 " + sh.epoch + " " + sh.activating + " " + sh.effective); + sh = stakeHistory[1]; + console.log("EPOCH 1 " + sh.epoch + " " + sh.activating + " " + sh.effective); + + const targetEpoch = epoch ? epoch : epochInfo.epoch; + const { effective, activating, deactivating } = + getStakeActivatingAndDeactivating( + stakeAccount.stake.delegation, + BigInt(targetEpoch), + stakeHistory + ); + + let status; + if (deactivating > 0) { + status = "deactivating"; + } else if (activating > 0) { + status = "activating"; + } else if (effective > 0) { + status = "active"; + } else { + status = "inactive"; + } + const inactive = + BigInt(stakeAccountLamports) - + effective - + stakeAccount.meta.rentExemptReserve; + + return { + status, + active: effective, + inactive, + }; +} diff --git a/tests/utils/types.ts b/tests/utils/types.ts index 078dc781a..33fbd1096 100644 --- a/tests/utils/types.ts +++ b/tests/utils/types.ts @@ -5,6 +5,8 @@ import BN from "bn.js"; export const I80F48_ZERO = bigNumberToWrappedI80F48(0); export const I80F48_ONE = bigNumberToWrappedI80F48(1); +/** Equivalent in value to u64::MAX in Rust */ +export const u64MAX_BN= new BN("18446744073709551615"); export type RiskTier = { collateral: {} } | { isolated: {} }; diff --git a/yarn.lock b/yarn.lock index 7590eb8dd..b24e9a965 100644 --- a/yarn.lock +++ b/yarn.lock @@ -643,6 +643,11 @@ agentkeepalive@^4.2.1, agentkeepalive@^4.3.0, agentkeepalive@^4.5.0: dependencies: humanize-ms "^1.2.1" +anchor-bankrun@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/anchor-bankrun/-/anchor-bankrun-0.4.0.tgz#7667f003f0948654c224b86948fb9691b7f84ba2" + integrity sha512-s+K7E0IGAlmkhuo8nbiqVsQf2yJ+3l9GjNQJSmkRDe25dQj4Yef9rJh77FH6EQ5H6yQYfzuhgm/5GD6JMjdTZg== + ansi-colors@^4.1.3: version "4.1.3" resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b" @@ -1505,6 +1510,45 @@ snake-case@^3.0.4: dot-case "^3.0.4" tslib "^2.0.3" +solana-bankrun-darwin-arm64@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/solana-bankrun-darwin-arm64/-/solana-bankrun-darwin-arm64-0.3.0.tgz#749ce9858bf61c4244a06c4e6d99338daa154909" + integrity sha512-+NbDncf0U6l3knuacRBiqpjZ2DSp+5lZaAU518gH7/x6qubbui/d000STaIBK+uNTPBS/AL/bCN+7PkXqmA3lA== + +solana-bankrun-darwin-universal@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/solana-bankrun-darwin-universal/-/solana-bankrun-darwin-universal-0.3.0.tgz#2d2932282bb1fe719430f2f53060cad3104ebb26" + integrity sha512-1/F0xdMa4qvc5o6z16FCCbZ5jbdvKvxpx5kyPcMWRiRPwyvi+zltMxciPAYMlg3wslQqGz88uFhrBEzq2eTumQ== + +solana-bankrun-darwin-x64@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/solana-bankrun-darwin-x64/-/solana-bankrun-darwin-x64-0.3.0.tgz#c814a13702a12ba085c32b20a66a063cffbe74a1" + integrity sha512-U6CANjkmMl+lgNA7UH0GKs5V7LtVIUDzJBZefGGqLfqUNv3EjA/PrrToM0hAOWJgkxSwdz6zW+p5sw5FmnbXtg== + +solana-bankrun-linux-x64-gnu@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/solana-bankrun-linux-x64-gnu/-/solana-bankrun-linux-x64-gnu-0.3.0.tgz#5c5dec2d4e01f755c9cf8fe9f791a8085bf94f51" + integrity sha512-qJSkCFs0k2n4XtTnyxGMiZsuqO2TiqTYgWjQ+3mZhGNUAMys/Vq8bd7/SyBm6RR7EfVuRXRxZvh+F8oKZ77V4w== + +solana-bankrun-linux-x64-musl@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/solana-bankrun-linux-x64-musl/-/solana-bankrun-linux-x64-musl-0.3.0.tgz#0df9434f03d1aa704b085f82e40cc6129b8eea09" + integrity sha512-xsS2CS2xb1Sw4ivNXM0gPz/qpW9BX0neSvt/pnok5L330Nu9xlTnKAY8FhzzqOP9P9sJlGRM787Y6d0yYwt6xQ== + +solana-bankrun@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/solana-bankrun/-/solana-bankrun-0.3.0.tgz#1183af008e00c565d6708f0c051588589e315d1c" + integrity sha512-YkH7sa8TB/AoRPzG17CXJtYsRIQHEkEqGLz1Vwc13taXhDBkjO7z6NI5JYw7n0ybRymDHwMYTc7sd+5J40TyVQ== + dependencies: + "@solana/web3.js" "^1.68.0" + bs58 "^4.0.1" + optionalDependencies: + solana-bankrun-darwin-arm64 "0.3.0" + solana-bankrun-darwin-universal "0.3.0" + solana-bankrun-darwin-x64 "0.3.0" + solana-bankrun-linux-x64-gnu "0.3.0" + solana-bankrun-linux-x64-musl "0.3.0" + source-map-support@^0.5.6: version "0.5.21" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" From d16dd065aea185d84d3a5deba6e07adf296dc16e Mon Sep 17 00:00:00 2001 From: Jon Gurary Date: Sun, 8 Sep 2024 10:46:00 -0400 Subject: [PATCH 05/52] Proof of concept for warping stake status ahead with bankrun --- tests/s01_usersStake.spec.ts | 87 +++++++++++++----------------------- tests/utils/stake-utils.ts | 56 +++++------------------ 2 files changed, 42 insertions(+), 101 deletions(-) diff --git a/tests/s01_usersStake.spec.ts b/tests/s01_usersStake.spec.ts index 58810cc53..0bd2ea351 100644 --- a/tests/s01_usersStake.spec.ts +++ b/tests/s01_usersStake.spec.ts @@ -136,63 +136,38 @@ describe("User stakes some native and creates an account", () => { ); } - bankrunContext.warpToEpoch(2n); - - clock = await client.getAccount(SYSVAR_CLOCK_PUBKEY); - // epoch is bytes 16-24 - epoch = new BN(clock.data.slice(16, 24), 10, "le").toNumber(); - if (verbose) { - console.log("Warped to epoch: " + epoch); - } + }); - const stakeStatusAfter2 = await getStakeActivation( - bankRunProvider.connection, - stakeAccount, - epoch - ); - if (verbose) { - console.log("It is now epoch: " + epoch); - console.log( - "Stake active: " + - stakeStatusAfter2.active.toLocaleString() + - " inactive " + - stakeStatusAfter2.inactive.toLocaleString() + - " status: " + - stakeStatusAfter2.status + it("(user 0) Init user account - happy path", async () => { + // TODO the stake program must be rewritten to use the bankrun provider... + const epoch = (await provider.connection.getEpochInfo()).epoch; + const stakeStatusAfter = await getStakeActivation( + provider.connection, + stakeAccount + ); + if (verbose) { + console.log("It is now epoch: " + epoch); + console.log( + "Stake active: " + + stakeStatusAfter.active.toLocaleString() + + " inactive " + + stakeStatusAfter.inactive.toLocaleString() + + " status: " + + stakeStatusAfter.status + ); + } + + let tx = new Transaction(); + + tx.add( + await program.methods + .initUser() + .accounts({ + payer: users[0].wallet.publicKey, + }) + .instruction() ); - } - }); - // it("(user 0) Init user account - happy path", async () => { - // // TODO the stake program must be rewritten to use the bankrun provider... - // const epoch = (await provider.connection.getEpochInfo()).epoch; - // const stakeStatusAfter = await getStakeActivation( - // provider.connection, - // stakeAccount - // ); - // if (verbose) { - // console.log("It is now epoch: " + epoch); - // console.log( - // "Stake active: " + - // stakeStatusAfter.active.toLocaleString() + - // " inactive " + - // stakeStatusAfter.inactive.toLocaleString() + - // " status: " + - // stakeStatusAfter.status - // ); - // } - - // let tx = new Transaction(); - - // tx.add( - // await program.methods - // .initUser() - // .accounts({ - // payer: users[0].wallet.publicKey, - // }) - // .instruction() - // ); - - // await users[0].userCollatizerProgram.provider.sendAndConfirm(tx); - // }); + await users[0].userCollatizerProgram.provider.sendAndConfirm(tx); + }); }); diff --git a/tests/utils/stake-utils.ts b/tests/utils/stake-utils.ts index 1dc381a69..6e5814928 100644 --- a/tests/utils/stake-utils.ts +++ b/tests/utils/stake-utils.ts @@ -219,33 +219,23 @@ export type StakeHistoryEntry = { * and modified to directly read from buffer * */ export const getStakeHistory = function (data: Buffer): StakeHistoryEntry[] { + // Note: Is just `Vec<(Epoch, StakeHistoryEntry)>` internally const stakeHistory: StakeHistoryEntry[] = []; - const entrySize = 24; // Each entry is 24 bytes (3 x 8-byte u64 fields) + const entrySize = 32; // Each entry is 32 bytes (4 x 8-byte u64 fields) - // TODO use account parsed and compare what the issue is.... for ( - let offset = 0, epoch = 0n; - offset + entrySize <= data.length; + // skip the first 8 bytes for the Vec overhead + let offset = 8, epoch = 0n; + offset + entrySize < data.length; offset += entrySize, epoch++ ) { - const effective = data.readBigUInt64LE(offset); // u64 effective - const activating = data.readBigUInt64LE(offset + 8); // u64 activating - const deactivating = data.readBigUInt64LE(offset + 16); // u64 deactivating - - if (epoch < 5) { - console.log( - "LOG " + - epoch + - ": e" + - effective + - " a" + - activating + - " d" + - deactivating - ); - } + const epoch = data.readBigUInt64LE(offset); // Note `epoch` is just a u64 renamed + const effective = data.readBigUInt64LE(offset + 8); // u64 effective + const activating = data.readBigUInt64LE(offset + 16); // u64 activating + const deactivating = data.readBigUInt64LE(offset + 24); // u64 deactivating + stakeHistory.push({ - epoch, // Inferred from the position in the stake history + epoch, effective, activating, deactivating, @@ -286,23 +276,7 @@ function getStakeHistoryEntry( stakeHistory: StakeHistoryEntry[] ): StakeHistoryEntry | null { for (const entry of stakeHistory) { - console.log( - "epoch read: " + - entry.epoch + - " " + - entry.activating + - " " + - entry.effective - ); if (entry.epoch === epoch) { - console.log( - "epoch found: " + - entry.epoch + - " " + - entry.activating + - " " + - entry.effective - ); return entry; } } @@ -342,9 +316,7 @@ export function getStakeAndActivating( } let currentEpoch = delegation.activationEpoch; - console.log("current: " + currentEpoch); let entry = getStakeHistoryEntry(currentEpoch, stakeHistory); - console.log("entry: " + entry.activating + " " + entry.deactivating); if (entry !== null) { // target_epoch > self.activation_epoch @@ -355,10 +327,8 @@ export function getStakeAndActivating( currentEpoch++; const remaining = delegation.stake - currentEffectiveStake; const weight = Number(remaining) / Number(entry.activating); - console.log(weight); const newlyEffectiveClusterStake = Number(entry.effective) * WARMUP_COOLDOWN_RATE; - console.log(newlyEffectiveClusterStake); const newlyEffectiveStake = BigInt( Math.max(1, Math.round(weight * newlyEffectiveClusterStake)) ); @@ -524,10 +494,6 @@ export async function getStakeActivation( return getStakeHistory(stakeHistoryInfo.data); })(), ]); - let sh = stakeHistory[0]; - console.log("EPOCH 0 " + sh.epoch + " " + sh.activating + " " + sh.effective); - sh = stakeHistory[1]; - console.log("EPOCH 1 " + sh.epoch + " " + sh.activating + " " + sh.effective); const targetEpoch = epoch ? epoch : epochInfo.epoch; const { effective, activating, deactivating } = From 2f12095ed3969b08817b99549995dcd0e87fcb7f Mon Sep 17 00:00:00 2001 From: Jon Gurary Date: Tue, 10 Sep 2024 16:25:44 -0400 Subject: [PATCH 06/52] Stake holding account and basic CPI structure --- programs/staking-collatizer/src/constants.rs | 3 + .../src/instructions/init_stakeholder.rs | 119 ++++++++++++++++++ .../src/instructions/init_user.rs | 19 +++ .../src/instructions/mod.rs | 2 + programs/staking-collatizer/src/lib.rs | 5 + programs/staking-collatizer/src/state/mod.rs | 2 + .../src/state/stake_user.rs | 2 +- .../src/state/stakeholder.rs | 29 +++++ tests/rootHooks.ts | 1 + tests/s01_usersStake.spec.ts | 2 + tests/s02_initStakeholder.spec.ts | 78 ++++++++++++ tests/utils/stakeCollatizer/pdas.ts | 26 ++++ 12 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 programs/staking-collatizer/src/constants.rs create mode 100644 programs/staking-collatizer/src/instructions/init_stakeholder.rs create mode 100644 programs/staking-collatizer/src/state/stakeholder.rs create mode 100644 tests/s02_initStakeholder.spec.ts create mode 100644 tests/utils/stakeCollatizer/pdas.ts diff --git a/programs/staking-collatizer/src/constants.rs b/programs/staking-collatizer/src/constants.rs new file mode 100644 index 000000000..a0bf6496e --- /dev/null +++ b/programs/staking-collatizer/src/constants.rs @@ -0,0 +1,3 @@ +pub const STAKEHOLDER_SEED: &str = "stakeholder"; +pub const STAKEHOLDER_STAKE_ACC_SEED: &str = "stakeacc"; +pub const STAKE_USER_SEED: &str = "stakeuser"; diff --git a/programs/staking-collatizer/src/instructions/init_stakeholder.rs b/programs/staking-collatizer/src/instructions/init_stakeholder.rs new file mode 100644 index 000000000..6e6105823 --- /dev/null +++ b/programs/staking-collatizer/src/instructions/init_stakeholder.rs @@ -0,0 +1,119 @@ +use anchor_lang::prelude::*; +use solana_program::{ + program::invoke_signed, + stake::state::{Authorized, Lockup}, + system_instruction, +}; + +use crate::{constants::{STAKEHOLDER_SEED, STAKEHOLDER_STAKE_ACC_SEED}, state::StakeHolder}; + +#[derive(Accounts)] +pub struct InitStakeHolder<'info> { + /// Pays the account initialization fee + #[account(mut)] + pub payer: Signer<'info>, + + /// CHECK: becomes the admin of the new account, unchecked + pub admin: UncheckedAccount<'info>, + + #[account( + init, + seeds = [ + STAKEHOLDER_SEED.as_bytes(), + vote_account.key().as_ref(), + admin.key().as_ref(), + ], + bump, + payer = payer, + space = 8 + StakeHolder::LEN, + )] + pub stakeholder: AccountLoader<'info, StakeHolder>, + + /// CHECK: used by CPI + pub vote_account: UncheckedAccount<'info>, + + /// CHECK: Stakeholder's stake account to be created, validated against seeds + #[account( + mut, + seeds = [ + STAKEHOLDER_STAKE_ACC_SEED.as_bytes(), + stakeholder.key().as_ref() + ], + bump, + )] + pub stake_account: UncheckedAccount<'info>, + + /// CHECK: Native stake program, checked against known hardcoded key + #[account( + constraint = stake_program.key() == solana_program::stake::program::ID + )] + pub stake_program: UncheckedAccount<'info>, + pub rent: Sysvar<'info, Rent>, + pub system_program: Program<'info, System>, +} + +pub fn init_stakeholder(ctx: Context) -> Result<()> { + let mut stakeholder = ctx.accounts.stakeholder.load_init()?; + + stakeholder.key = ctx.accounts.stakeholder.key(); + stakeholder.admin = ctx.accounts.admin.key(); + stakeholder.vote_account = ctx.accounts.vote_account.key(); + stakeholder.stake_account = ctx.accounts.stake_account.key(); + + stakeholder.net_delegation = 0; + + // Create the stake account owned by the Stakeholder PDA + let space = solana_program::stake::state::StakeStateV2::size_of(); + let rent_exempt_lamports = ctx.accounts.rent.minimum_balance(space); + + let create_stake_account_ix = system_instruction::create_account( + &ctx.accounts.payer.key(), + &ctx.accounts.stake_account.key(), + rent_exempt_lamports, + space as u64, + &ctx.accounts.stake_program.key(), + ); + + invoke_signed( + &create_stake_account_ix, + &[ + ctx.accounts.payer.to_account_info(), + ctx.accounts.stake_account.to_account_info(), + ctx.accounts.system_program.to_account_info(), + ], + &[&[ + STAKEHOLDER_STAKE_ACC_SEED.as_bytes(), + ctx.accounts.stakeholder.key().as_ref(), + &[ctx.bumps.stake_account], + ]], + )?; + + // Initialize the stake account for the Stakeholder PDA + let authorized = Authorized { + staker: ctx.accounts.stakeholder.key(), + withdrawer: ctx.accounts.stakeholder.key(), + }; + let lockup = Lockup::default(); + + let init_stake_account_ix = solana_program::stake::instruction::initialize( + &ctx.accounts.stake_account.key(), + &authorized, + &lockup, + ); + + invoke_signed( + &init_stake_account_ix, + &[ + ctx.accounts.stake_account.to_account_info(), + ctx.accounts.stake_program.to_account_info(), + ctx.accounts.rent.to_account_info(), + ], + &[&[ + STAKEHOLDER_STAKE_ACC_SEED.as_bytes(), + ctx.accounts.stakeholder.key().as_ref(), + &[ctx.bumps.stake_account], + ]], + )?; + + Ok(()) +} diff --git a/programs/staking-collatizer/src/instructions/init_user.rs b/programs/staking-collatizer/src/instructions/init_user.rs index 478caed86..c9ee1ebc0 100644 --- a/programs/staking-collatizer/src/instructions/init_user.rs +++ b/programs/staking-collatizer/src/instructions/init_user.rs @@ -1,8 +1,27 @@ use anchor_lang::prelude::*; +use crate::{constants::STAKE_USER_SEED, state::StakeUser}; + #[derive(Accounts)] pub struct InitUser<'info> { + /// Pays the account initialization fee + #[account(mut)] pub payer: Signer<'info>, + + #[account( + init, + seeds = [ + STAKE_USER_SEED.as_bytes(), + payer.key().as_ref(), + ], + bump, + payer = payer, + space = 8 + StakeUser::LEN, + )] + pub stake_user: AccountLoader<'info, StakeUser>, + + pub rent: Sysvar<'info, Rent>, + pub system_program: Program<'info, System>, } pub fn init_user(ctx: Context) -> Result<()> { diff --git a/programs/staking-collatizer/src/instructions/mod.rs b/programs/staking-collatizer/src/instructions/mod.rs index 873fc8930..42866eec3 100644 --- a/programs/staking-collatizer/src/instructions/mod.rs +++ b/programs/staking-collatizer/src/instructions/mod.rs @@ -1,3 +1,5 @@ +pub mod init_stakeholder; pub mod init_user; +pub use init_stakeholder::*; pub use init_user::*; diff --git a/programs/staking-collatizer/src/lib.rs b/programs/staking-collatizer/src/lib.rs index a33d0e222..f15f4b380 100644 --- a/programs/staking-collatizer/src/lib.rs +++ b/programs/staking-collatizer/src/lib.rs @@ -2,6 +2,7 @@ use anchor_lang::prelude::*; declare_id!("65e81uBnLPtUNaFbgzeU4gMwmCbMeeh6GCLDhEVaNNon"); +pub mod constants; pub mod errors; pub mod instructions; pub mod macros; @@ -19,4 +20,8 @@ pub mod staking_collatizer { pub fn init_user(ctx: Context) -> Result<()> { instructions::init_user::init_user(ctx) } + + pub fn init_stakeholder(ctx: Context) -> Result<()> { + instructions::init_stakeholder::init_stakeholder(ctx) + } } diff --git a/programs/staking-collatizer/src/state/mod.rs b/programs/staking-collatizer/src/state/mod.rs index 6c381b99b..7107e0fb2 100644 --- a/programs/staking-collatizer/src/state/mod.rs +++ b/programs/staking-collatizer/src/state/mod.rs @@ -1,3 +1,5 @@ pub mod stake_user; +pub mod stakeholder; pub use stake_user::*; +pub use stakeholder::*; diff --git a/programs/staking-collatizer/src/state/stake_user.rs b/programs/staking-collatizer/src/state/stake_user.rs index 089f18f9c..0f3e19da8 100644 --- a/programs/staking-collatizer/src/state/stake_user.rs +++ b/programs/staking-collatizer/src/state/stake_user.rs @@ -1,6 +1,6 @@ use anchor_lang::prelude::*; -#[account()] +#[account(zero_copy)] pub struct StakeUser { /// The account's own key pub key: Pubkey, diff --git a/programs/staking-collatizer/src/state/stakeholder.rs b/programs/staking-collatizer/src/state/stakeholder.rs new file mode 100644 index 000000000..6182583b2 --- /dev/null +++ b/programs/staking-collatizer/src/state/stakeholder.rs @@ -0,0 +1,29 @@ +use anchor_lang::prelude::*; + +/// A PDA that holds the assets delegated to this program and tracks various information about its holdings. +#[account(zero_copy)] +pub struct StakeHolder { + /// The account's own key, a pda of `vote_account`, `admin, and b"stakeholder" + pub key: Pubkey, + + /// Currently unused + pub admin: Pubkey, + + /// The validator's vote account where stake is delegated + pub vote_account: Pubkey, + + /// The stake account where held stake is stored + pub stake_account: Pubkey, + + // TODO also track program-wide (or admin-wide) delegation on another struct + /// Net SOL controlled by this account + /// * In SOL, in native decimals (lamports) + pub net_delegation: u64, + + /// Reserved for future use + pub reserved0: [u8; 512], +} + +impl StakeHolder { + pub const LEN: usize = std::mem::size_of::(); +} diff --git a/tests/rootHooks.ts b/tests/rootHooks.ts index 860b5f4d2..4fd0c00a0 100644 --- a/tests/rootHooks.ts +++ b/tests/rootHooks.ts @@ -27,6 +27,7 @@ export let oracles: Oracles = undefined; export const verbose = true; /** The program owner is also the provider wallet */ export let globalProgramAdmin: MockUser = undefined; +/** Administers the mrgnlend group and/or stake holder accounts */ export let groupAdmin: MockUser = undefined; /** Administers valiator votes and withdraws */ export let validatorAdmin: MockUser = undefined; diff --git a/tests/s01_usersStake.spec.ts b/tests/s01_usersStake.spec.ts index 0bd2ea351..c8ac4d7df 100644 --- a/tests/s01_usersStake.spec.ts +++ b/tests/s01_usersStake.spec.ts @@ -168,6 +168,8 @@ describe("User stakes some native and creates an account", () => { .instruction() ); + // TODO check the account... + await users[0].userCollatizerProgram.provider.sendAndConfirm(tx); }); }); diff --git a/tests/s02_initStakeholder.spec.ts b/tests/s02_initStakeholder.spec.ts new file mode 100644 index 000000000..0cffd1ca6 --- /dev/null +++ b/tests/s02_initStakeholder.spec.ts @@ -0,0 +1,78 @@ +import { + AnchorProvider, + BN, + getProvider, + Program, + Wallet, + workspace, +} from "@coral-xyz/anchor"; +import { + Connection, + LAMPORTS_PER_SOL, + PublicKey, + SYSVAR_CLOCK_PUBKEY, + SYSVAR_RENT_PUBKEY, + SYSVAR_STAKE_HISTORY_PUBKEY, + Transaction, +} from "@solana/web3.js"; +import { groupAdmin, users, validators, verbose } from "./rootHooks"; +import { StakingCollatizer } from "../target/types/staking_collatizer"; +import { + createStakeAccount, + delegateStake, + getStakeAccount, + getStakeActivation, +} from "./utils/stake-utils"; +import { assertBNEqual, assertKeysEqual } from "./utils/genericTests"; +import { u64MAX_BN } from "./utils/types"; + +import path from "path"; +import { BankrunProvider } from "anchor-bankrun"; +import type { ProgramTestContext } from "solana-bankrun"; +import { Clock, startAnchor } from "solana-bankrun"; +import { + deriveStakeHolder, + deriveStakeHolderStakeAccount, +} from "./utils/stakeCollatizer/pdas"; + +describe("Create a stake holder for validator", () => { + const program = workspace.StakingCollatizer as Program; + const provider = getProvider() as AnchorProvider; + const wallet = provider.wallet as Wallet; + + it("(admin) Create stake holder for validator 0", async () => { + let tx = new Transaction(); + + tx.add( + await program.methods + .initStakeholder() + .accounts({ + payer: groupAdmin.wallet.publicKey, + admin: groupAdmin.wallet.publicKey, + voteAccount: validators[0].voteAccount, + stakeProgram: new PublicKey( + "Stake11111111111111111111111111111111111111" + ), + }) + .instruction() + ); + + await groupAdmin.userCollatizerProgram.provider.sendAndConfirm(tx); + + const [stakeholderKey] = deriveStakeHolder( + program.programId, + validators[0].voteAccount, + groupAdmin.wallet.publicKey + ); + const [stakeholderStakeAcc] = deriveStakeHolderStakeAccount( + program.programId, + stakeholderKey + ); + let sh = await program.account.stakeHolder.fetch(stakeholderKey); + assertKeysEqual(sh.key, stakeholderKey); + assertKeysEqual(sh.admin, groupAdmin.wallet.publicKey); + assertKeysEqual(sh.voteAccount, validators[0].voteAccount); + assertKeysEqual(sh.stakeAccount, stakeholderStakeAcc); + assertBNEqual(sh.netDelegation, 0); + }); +}); diff --git a/tests/utils/stakeCollatizer/pdas.ts b/tests/utils/stakeCollatizer/pdas.ts new file mode 100644 index 000000000..26b089ed8 --- /dev/null +++ b/tests/utils/stakeCollatizer/pdas.ts @@ -0,0 +1,26 @@ +import { PublicKey } from "@solana/web3.js"; + +export const deriveStakeHolder = ( + programId: PublicKey, + voteAccount: PublicKey, + admin: PublicKey +) => { + return PublicKey.findProgramAddressSync( + [ + Buffer.from("stakeholder", "utf-8"), + voteAccount.toBuffer(), + admin.toBuffer(), + ], + programId + ); +}; + +export const deriveStakeHolderStakeAccount = ( + programId: PublicKey, + stakeholder: PublicKey +) => { + return PublicKey.findProgramAddressSync( + [Buffer.from("stakeacc", "utf-8"), stakeholder.toBuffer()], + programId + ); +}; From ae17eb8a7e18f3115042c92f451a99bdc702a4b5 Mon Sep 17 00:00:00 2001 From: Jon Gurary Date: Wed, 11 Sep 2024 18:46:36 -0400 Subject: [PATCH 07/52] WIP deposit stake ix PoC --- .../src/instructions/deposit_stake.rs | 79 ++++++++++ .../src/instructions/init_stakeholder.rs | 1 + .../src/instructions/init_user.rs | 10 +- .../src/instructions/mod.rs | 2 + programs/staking-collatizer/src/lib.rs | 4 + tests/rootHooks.ts | 56 +++++++- tests/s01_usersStake.spec.ts | 136 ++++++++---------- tests/s02_initStakeholder.spec.ts | 72 ++++++---- tests/utils/mocks.ts | 10 +- tests/utils/stake-utils.ts | 60 +++----- tests/utils/stakeCollatizer/pdas.ts | 7 + 11 files changed, 284 insertions(+), 153 deletions(-) create mode 100644 programs/staking-collatizer/src/instructions/deposit_stake.rs diff --git a/programs/staking-collatizer/src/instructions/deposit_stake.rs b/programs/staking-collatizer/src/instructions/deposit_stake.rs new file mode 100644 index 000000000..18ae82f11 --- /dev/null +++ b/programs/staking-collatizer/src/instructions/deposit_stake.rs @@ -0,0 +1,79 @@ +use anchor_lang::prelude::*; +use solana_program::{ + program::invoke_signed, + stake::state::{Authorized, Lockup, StakeAuthorize}, + system_instruction, +}; + +use crate::{constants::{STAKEHOLDER_SEED, STAKEHOLDER_STAKE_ACC_SEED}, state::StakeHolder}; + +#[derive(Accounts)] +pub struct DepositStake<'info> { + + #[account(mut)] + pub admin: Signer<'info>, + + /// The `user_stake_account`'s authority must also sign. This supports use cases where the admin + /// is moving stake from another wallet they control. + pub stake_authority: Signer<'info>, + + // TODO add user accounts (stakeUser, etc) + + // TODO check admin, stake acc, etc + #[account( + mut + )] + pub stakeholder: AccountLoader<'info, StakeHolder>, + + // /// CHECK: User's stake account (active and delegated to a validator), used by cpi + // #[account(mut)] + // pub user_stake_account: UncheckedAccount<'info>, + + // /// CHECK: Stakeholder's stake account, validated against seeds + // #[account( + // mut, + // seeds = [ + // STAKEHOLDER_STAKE_ACC_SEED.as_bytes(), + // stakeholder.key().as_ref() + // ], + // bump, + // )] + // pub stake_account: UncheckedAccount<'info>, + + /// CHECK: Native stake program, checked against known hardcoded key + #[account( + constraint = stake_program.key() == solana_program::stake::program::ID + )] + pub stake_program: UncheckedAccount<'info>, + /// Sysvar required by the Solana staking program + pub clock: Sysvar<'info, Clock>, + pub system_program: Program<'info, System>, +} + +pub fn deposit_stake(ctx: Context) -> Result<()> { + msg!("Start deposit"); + // let mut stakeholder = ctx.accounts.stakeholder.load_mut()?; + + // let authorize_ix = solana_program::stake::instruction::authorize( + // &ctx.accounts.user_stake_account.key(), // User's stake account + // &ctx.accounts.stake_authority.key(), // Current authorized staker + // &ctx.accounts.stakeholder.key(), // Program stakeholder becomes the new staker + // StakeAuthorize::Staker, + // None + // ); + + // invoke_signed( + // &authorize_ix, + // &[ + // ctx.accounts.user_stake_account.to_account_info(), + // ctx.accounts.clock.to_account_info(), + // ctx.accounts.stake_authority.to_account_info(), + // ctx.accounts.stake_program.to_account_info(), + // ], + // &[], + // )?; + + msg!("Done transfer authority"); + + Ok(()) +} diff --git a/programs/staking-collatizer/src/instructions/init_stakeholder.rs b/programs/staking-collatizer/src/instructions/init_stakeholder.rs index 6e6105823..651537271 100644 --- a/programs/staking-collatizer/src/instructions/init_stakeholder.rs +++ b/programs/staking-collatizer/src/instructions/init_stakeholder.rs @@ -29,6 +29,7 @@ pub struct InitStakeHolder<'info> { )] pub stakeholder: AccountLoader<'info, StakeHolder>, + // TODO remove? /// CHECK: used by CPI pub vote_account: UncheckedAccount<'info>, diff --git a/programs/staking-collatizer/src/instructions/init_user.rs b/programs/staking-collatizer/src/instructions/init_user.rs index c9ee1ebc0..dacc9421d 100644 --- a/programs/staking-collatizer/src/instructions/init_user.rs +++ b/programs/staking-collatizer/src/instructions/init_user.rs @@ -8,6 +8,8 @@ pub struct InitUser<'info> { #[account(mut)] pub payer: Signer<'info>, + // TODO owner seperate from payer + #[account( init, seeds = [ @@ -25,9 +27,9 @@ pub struct InitUser<'info> { } pub fn init_user(ctx: Context) -> Result<()> { - msg!( - "Nothing was done. Signed by: {:?}", - ctx.accounts.payer.key() - ); + let mut stake_user = ctx.accounts.stake_user.load_init()?; + + stake_user.key = ctx.accounts.stake_user.key(); + Ok(()) } diff --git a/programs/staking-collatizer/src/instructions/mod.rs b/programs/staking-collatizer/src/instructions/mod.rs index 42866eec3..e0d239635 100644 --- a/programs/staking-collatizer/src/instructions/mod.rs +++ b/programs/staking-collatizer/src/instructions/mod.rs @@ -1,5 +1,7 @@ +pub mod deposit_stake; pub mod init_stakeholder; pub mod init_user; +pub use deposit_stake::*; pub use init_stakeholder::*; pub use init_user::*; diff --git a/programs/staking-collatizer/src/lib.rs b/programs/staking-collatizer/src/lib.rs index f15f4b380..4ffa1a9f1 100644 --- a/programs/staking-collatizer/src/lib.rs +++ b/programs/staking-collatizer/src/lib.rs @@ -24,4 +24,8 @@ pub mod staking_collatizer { pub fn init_stakeholder(ctx: Context) -> Result<()> { instructions::init_stakeholder::init_stakeholder(ctx) } + + pub fn deposit_stake(ctx: Context) -> Result<()> { + instructions::deposit_stake::deposit_stake(ctx) + } } diff --git a/tests/rootHooks.ts b/tests/rootHooks.ts index 4fd0c00a0..3cefe524d 100644 --- a/tests/rootHooks.ts +++ b/tests/rootHooks.ts @@ -14,13 +14,19 @@ import { Marginfi } from "../target/types/marginfi"; import { Keypair, PublicKey, + StakeProgram, SystemProgram, + SYSVAR_EPOCH_SCHEDULE_PUBKEY, + SYSVAR_STAKE_HISTORY_PUBKEY, Transaction, VoteInit, VoteProgram, } from "@solana/web3.js"; import { setupPythOracles } from "./utils/pyth_mocks"; import { StakingCollatizer } from "../target/types/staking_collatizer"; +import { BankrunProvider } from "anchor-bankrun"; +import { BanksClient, ProgramTestContext, startAnchor } from "solana-bankrun"; +import path from "path"; export const ecosystem: Ecosystem = getGenericEcosystem(); export let oracles: Oracles = undefined; @@ -44,6 +50,13 @@ export const bankKeypairUsdc = Keypair.generate(); /** Bank for token A */ export const bankKeypairA = Keypair.generate(); +export let bankrunContext: ProgramTestContext; +export let bankRunProvider: BankrunProvider; +export let bankrunProgram: Program; +export let banksClient: BanksClient; +/** keys copied into the bankrun instance */ +let copyKeys: PublicKey[] = []; + export const mochaHooks = { beforeAll: async () => { const mrgnProgram = workspace.Marginfi as Program; @@ -88,6 +101,7 @@ export const mochaHooks = { tx.add(...bIxes); await provider.sendAndConfirm(tx, [usdcMint, aMint, bMint]); + copyKeys.push(usdcMint.publicKey, aMint.publicKey, bMint.publicKey); const setupUserOptions: SetupTestUserOptions = { marginProgram: mrgnProgram, @@ -106,6 +120,8 @@ export const mochaHooks = { wallet.payer, setupUserOptions ); + copyKeys.push(groupAdmin.usdcAccount); + copyKeys.push(groupAdmin.wallet.publicKey); for (let i = 0; i < numUsers; i++) { const user = await setupTestUser( @@ -113,7 +129,7 @@ export const mochaHooks = { wallet.payer, setupUserOptions ); - users.push(user); + addUser(user); } // Global admin uses the payer wallet... @@ -146,8 +162,27 @@ export const mochaHooks = { if (verbose) { console.log("Validator vote acc [" + i + "]: " + validator.voteAccount); } - validators.push(validator); + addValidator(validator); } + + copyKeys.push(StakeProgram.programId); + copyKeys.push(SYSVAR_STAKE_HISTORY_PUBKEY); + + const accountKeys = copyKeys; + + const accounts = await provider.connection.getMultipleAccountsInfo( + accountKeys + ); + const addedAccounts = accountKeys.map((address, index) => ({ + address, + info: accounts[index], + })); + + bankrunContext = await startAnchor(path.resolve(), [], addedAccounts); + bankRunProvider = new BankrunProvider(bankrunContext); + bankrunProgram = new Program(collatProgram.idl, bankRunProvider); + banksClient = bankrunContext.banksClient; + if (verbose) { console.log("---End ecosystem setup---"); console.log(""); @@ -155,6 +190,23 @@ export const mochaHooks = { }, }; +const addValidator = (validator: Validator) => { + validators.push(validator); + // copyKeys.push(validator.authorizedVoter); + // copyKeys.push(validator.authorizedWithdrawer); + // copyKeys.push(validator.node); + copyKeys.push(validator.voteAccount); +}; + +const addUser = (user: MockUser) => { + users.push(user); + // copyKeys.push(user.tokenAAccount); + // copyKeys.push(user.tokenBAccount); + copyKeys.push(user.usdcAccount); + copyKeys.push(user.wallet.publicKey); + // copyKeys.push(user.wsolAccount); +}; + /** * Create a mock validator with given vote/withdraw authority * diff --git a/tests/s01_usersStake.spec.ts b/tests/s01_usersStake.spec.ts index c8ac4d7df..ca2de2a11 100644 --- a/tests/s01_usersStake.spec.ts +++ b/tests/s01_usersStake.spec.ts @@ -3,17 +3,23 @@ import { BN, getProvider, Program, - Wallet, workspace, } from "@coral-xyz/anchor"; import { - Connection, LAMPORTS_PER_SOL, PublicKey, SYSVAR_CLOCK_PUBKEY, Transaction, } from "@solana/web3.js"; -import { users, validators, verbose } from "./rootHooks"; +import { + bankrunProgram as bankrunProgram, + bankrunContext, + bankRunProvider, + users, + validators, + verbose, + banksClient, +} from "./rootHooks"; import { StakingCollatizer } from "../target/types/staking_collatizer"; import { createStakeAccount, @@ -24,40 +30,46 @@ import { import { assertBNEqual, assertKeysEqual } from "./utils/genericTests"; import { u64MAX_BN } from "./utils/types"; -import path from "path"; -import { BankrunProvider } from "anchor-bankrun"; -import type { ProgramTestContext } from "solana-bankrun"; -import { Clock, startAnchor } from "solana-bankrun"; +import { deriveStakeUser } from "./utils/stakeCollatizer/pdas"; describe("User stakes some native and creates an account", () => { const program = workspace.StakingCollatizer as Program; - const provider = getProvider() as AnchorProvider; - const wallet = provider.wallet as Wallet; - - let bankrunContext: ProgramTestContext; - let bankRunProvider: BankrunProvider; - let bankrunProgram: Program; let stakeAccount: PublicKey; it("(user 0) Create user stake account and stake to validator", async () => { - stakeAccount = await createStakeAccount( + let { createTx, stakeAccountKeypair } = createStakeAccount( users[0], - provider, 10 * LAMPORTS_PER_SOL ); + createTx.recentBlockhash = bankrunContext.lastBlockhash; + createTx.sign(users[0].wallet, stakeAccountKeypair); + await banksClient.processTransaction(createTx); + stakeAccount = stakeAccountKeypair.publicKey; + + if (verbose) { + console.log("Create stake account: " + stakeAccount); + console.log(" Stake: " + 10 / LAMPORTS_PER_SOL + " SOL"); + } + users[0].accounts.set("v0_stakeacc", stakeAccountKeypair.publicKey); - await delegateStake( + let delegateTx = delegateStake( users[0], - provider, stakeAccount, - validators[0].voteAccount, - verbose, - "user 0" + validators[0].voteAccount ); + delegateTx.recentBlockhash = bankrunContext.lastBlockhash; + delegateTx.sign(users[0].wallet); + await banksClient.processTransaction(delegateTx); + + if (verbose) { + console.log("user 0 delegated to " + validators[0].voteAccount); + } - const epochBefore = (await provider.connection.getEpochInfo()).epoch; - const stakeAccountInfo = await provider.connection.getAccountInfo( + let clock = await banksClient.getAccount(SYSVAR_CLOCK_PUBKEY); + // epoch is bytes 16-24 + let epochBefore = new BN(clock.data.slice(16, 24), 10, "le").toNumber(); + const stakeAccountInfo = await bankRunProvider.connection.getAccountInfo( stakeAccount ); const stakeAccBefore = getStakeAccount(stakeAccountInfo.data); @@ -74,8 +86,9 @@ describe("User stakes some native and creates an account", () => { assertBNEqual(new BN(delegation.deactivationEpoch.toString()), u64MAX_BN); const stakeStatusBefore = await getStakeActivation( - provider.connection, - stakeAccount + bankRunProvider.connection, + stakeAccount, + epochBefore ); if (verbose) { console.log("It is now epoch: " + epochBefore); @@ -91,28 +104,9 @@ describe("User stakes some native and creates an account", () => { }); it("Advance the epoch", async () => { - // Load the necessary accounts and add them to the bankrun context - const accountKeys = [ - stakeAccount, - users[0].wallet.publicKey, - validators[0].voteAccount, - ]; - const accounts = await program.provider.connection.getMultipleAccountsInfo( - accountKeys - ); - const addedAccounts = accountKeys.map((address, index) => ({ - address, - info: accounts[index], - })); - - bankrunContext = await startAnchor(path.resolve(), [], addedAccounts); - bankRunProvider = new BankrunProvider(bankrunContext); - bankrunProgram = new Program(program.idl, provider); - const client = bankrunContext.banksClient; - bankrunContext.warpToEpoch(1n); - let clock = await client.getAccount(SYSVAR_CLOCK_PUBKEY); + let clock = await banksClient.getAccount(SYSVAR_CLOCK_PUBKEY); // epoch is bytes 16-24 let epoch = new BN(clock.data.slice(16, 24), 10, "le").toNumber(); if (verbose) { @@ -135,41 +129,29 @@ describe("User stakes some native and creates an account", () => { stakeStatusAfter1.status ); } - }); - it("(user 0) Init user account - happy path", async () => { - // TODO the stake program must be rewritten to use the bankrun provider... - const epoch = (await provider.connection.getEpochInfo()).epoch; - const stakeStatusAfter = await getStakeActivation( - provider.connection, - stakeAccount - ); - if (verbose) { - console.log("It is now epoch: " + epoch); - console.log( - "Stake active: " + - stakeStatusAfter.active.toLocaleString() + - " inactive " + - stakeStatusAfter.inactive.toLocaleString() + - " status: " + - stakeStatusAfter.status - ); - } - - let tx = new Transaction(); - - tx.add( - await program.methods - .initUser() - .accounts({ - payer: users[0].wallet.publicKey, - }) - .instruction() - ); + it("(user 0) Init user account - happy path", async () => { + let tx = new Transaction(); - // TODO check the account... + tx.add( + await program.methods + .initUser() + .accounts({ + payer: users[0].wallet.publicKey, + }) + .instruction() + ); - await users[0].userCollatizerProgram.provider.sendAndConfirm(tx); - }); + const [stakeUserKey] = deriveStakeUser( + program.programId, + users[0].wallet.publicKey + ); + tx.recentBlockhash = bankrunContext.lastBlockhash; + tx.sign(users[0].wallet); + await banksClient.processTransaction(tx); + + let userAcc = await bankrunProgram.account.stakeUser.fetch(stakeUserKey); + assertKeysEqual(userAcc.key, stakeUserKey); + }); }); diff --git a/tests/s02_initStakeholder.spec.ts b/tests/s02_initStakeholder.spec.ts index 0cffd1ca6..11570390b 100644 --- a/tests/s02_initStakeholder.spec.ts +++ b/tests/s02_initStakeholder.spec.ts @@ -1,35 +1,22 @@ import { AnchorProvider, - BN, getProvider, Program, Wallet, workspace, } from "@coral-xyz/anchor"; +import { PublicKey, StakeProgram, Transaction } from "@solana/web3.js"; import { - Connection, - LAMPORTS_PER_SOL, - PublicKey, - SYSVAR_CLOCK_PUBKEY, - SYSVAR_RENT_PUBKEY, - SYSVAR_STAKE_HISTORY_PUBKEY, - Transaction, -} from "@solana/web3.js"; -import { groupAdmin, users, validators, verbose } from "./rootHooks"; + bankrunProgram, + bankrunContext, + groupAdmin, + validators, + banksClient, + users, +} from "./rootHooks"; import { StakingCollatizer } from "../target/types/staking_collatizer"; -import { - createStakeAccount, - delegateStake, - getStakeAccount, - getStakeActivation, -} from "./utils/stake-utils"; import { assertBNEqual, assertKeysEqual } from "./utils/genericTests"; -import { u64MAX_BN } from "./utils/types"; -import path from "path"; -import { BankrunProvider } from "anchor-bankrun"; -import type { ProgramTestContext } from "solana-bankrun"; -import { Clock, startAnchor } from "solana-bankrun"; import { deriveStakeHolder, deriveStakeHolderStakeAccount, @@ -50,14 +37,14 @@ describe("Create a stake holder for validator", () => { payer: groupAdmin.wallet.publicKey, admin: groupAdmin.wallet.publicKey, voteAccount: validators[0].voteAccount, - stakeProgram: new PublicKey( - "Stake11111111111111111111111111111111111111" - ), + stakeProgram: StakeProgram.programId, }) .instruction() ); - await groupAdmin.userCollatizerProgram.provider.sendAndConfirm(tx); + tx.recentBlockhash = bankrunContext.lastBlockhash; + tx.sign(groupAdmin.wallet); + await banksClient.processTransaction(tx); const [stakeholderKey] = deriveStakeHolder( program.programId, @@ -68,11 +55,44 @@ describe("Create a stake holder for validator", () => { program.programId, stakeholderKey ); - let sh = await program.account.stakeHolder.fetch(stakeholderKey); + let sh = await bankrunProgram.account.stakeHolder.fetch(stakeholderKey); assertKeysEqual(sh.key, stakeholderKey); assertKeysEqual(sh.admin, groupAdmin.wallet.publicKey); assertKeysEqual(sh.voteAccount, validators[0].voteAccount); assertKeysEqual(sh.stakeAccount, stakeholderStakeAcc); assertBNEqual(sh.netDelegation, 0); }); + + // TODO move to new file + it("(user 0) deposit stake to holder in exchange for collateral", async () => { + let tx = new Transaction(); + + let stakeAcc = users[0].accounts.get("v0_stakeacc"); + console.log("read stake acc: " + stakeAcc); + const [stakeholderKey] = deriveStakeHolder( + program.programId, + validators[0].voteAccount, + groupAdmin.wallet.publicKey + ); + + tx.add( + await program.methods + .depositStake() + .accounts({ + admin: users[0].wallet.publicKey, + stakeAuthority: users[0].wallet.publicKey, + stakeholder: stakeholderKey, + // userStakeAccount: stakeAcc, + stakeProgram: StakeProgram.programId, + }) + .instruction() + ); + + tx.recentBlockhash = bankrunContext.lastBlockhash; + tx.sign(users[0].wallet); + let res = await banksClient.processTransaction(tx); + console.log("res " + res); + + + }); }); diff --git a/tests/utils/mocks.ts b/tests/utils/mocks.ts index e83148cd0..e9ec6dd65 100644 --- a/tests/utils/mocks.ts +++ b/tests/utils/mocks.ts @@ -98,8 +98,13 @@ export type mockUser = { usdcAccount: PublicKey; /** A marginfi program that uses the user's wallet */ userMarginProgram: Program | undefined; - /** A staking collatizer program that uses the user's wallet */ + /** + * A staking collatizer program that uses the user's wallet. + * * NOTE: When testing, you will most likely use BankRun's client instead! + */ userCollatizerProgram: Program | undefined; + /** A map to store arbitrary accounts related to the user using a string key */ + accounts: Map; }; /** @@ -217,6 +222,7 @@ export const setupTestUser = async ( userCollatizerProgram: options.marginProgram ? getUserCollatizerProgram(options.collatizerProgram, userWalletKeypair) : undefined, + accounts: new Map(), }; return user; }; @@ -375,4 +381,4 @@ export type Validator = { authorizedVoter: PublicKey; authorizedWithdrawer: PublicKey; voteAccount: PublicKey; -} \ No newline at end of file +}; diff --git a/tests/utils/stake-utils.ts b/tests/utils/stake-utils.ts index 6e5814928..61f211c73 100644 --- a/tests/utils/stake-utils.ts +++ b/tests/utils/stake-utils.ts @@ -1,14 +1,9 @@ -import { AnchorProvider } from "@coral-xyz/anchor"; import { Keypair, Transaction, SystemProgram, StakeProgram, PublicKey, - LAMPORTS_PER_SOL, - AccountInfo, - ParsedAccountData, - RpcResponseAndContext, Connection, } from "@solana/web3.js"; import { mockUser } from "./mocks"; @@ -16,17 +11,10 @@ import { mockUser } from "./mocks"; /** * Create a stake account for some user * @param user - * @param provider * @param amount - in SOL (lamports), in native decimals - * @param verbose * @returns */ -export const createStakeAccount = async ( - user: mockUser, - provider: AnchorProvider, - amount: number, - verbose: boolean = true -) => { +export const createStakeAccount = (user: mockUser, amount: number) => { const stakeAccount = Keypair.generate(); const userPublicKey = user.wallet.publicKey; @@ -48,44 +36,25 @@ export const createStakeAccount = async ( }) ); - await provider.sendAndConfirm(tx, [user.wallet, stakeAccount]); - - if (verbose) { - console.log("Create stake account: " + stakeAccount.publicKey); - console.log(" Stake: " + amount / LAMPORTS_PER_SOL + " SOL"); - } - return stakeAccount.publicKey; + return { createTx: tx, stakeAccountKeypair: stakeAccount }; }; /** * Delegate a stake account to a validator. * @param user - wallet signs - * @param provider * @param stakeAccount * @param validatorVoteAccount - * @param verbose */ -export const delegateStake = async ( +export const delegateStake = ( user: mockUser, - provider: AnchorProvider, stakeAccount: PublicKey, - validatorVoteAccount: PublicKey, - verbose: boolean = true, - userDisplayName: string = "some user" + validatorVoteAccount: PublicKey ) => { - const tx = new Transaction().add( - StakeProgram.delegate({ - stakePubkey: stakeAccount, - authorizedPubkey: user.wallet.publicKey, - votePubkey: validatorVoteAccount, - }) - ); - - await provider.sendAndConfirm(tx, [user.wallet]); - - if (verbose) { - console.log(userDisplayName + " delegated to " + validatorVoteAccount); - } + return StakeProgram.delegate({ + stakePubkey: stakeAccount, + authorizedPubkey: user.wallet.publicKey, + votePubkey: validatorVoteAccount, + }); }; /** @@ -225,15 +194,22 @@ export const getStakeHistory = function (data: Buffer): StakeHistoryEntry[] { for ( // skip the first 8 bytes for the Vec overhead - let offset = 8, epoch = 0n; + let offset = 8; offset + entrySize < data.length; - offset += entrySize, epoch++ + offset += entrySize ) { const epoch = data.readBigUInt64LE(offset); // Note `epoch` is just a u64 renamed const effective = data.readBigUInt64LE(offset + 8); // u64 effective const activating = data.readBigUInt64LE(offset + 16); // u64 activating const deactivating = data.readBigUInt64LE(offset + 24); // u64 deactivating + // if (epoch < 10 && offset < 300) { + // console.log("epoch " + epoch); + // console.log("e " + effective); + // console.log("a " + activating); + // console.log("d " + deactivating); + // } + stakeHistory.push({ epoch, effective, diff --git a/tests/utils/stakeCollatizer/pdas.ts b/tests/utils/stakeCollatizer/pdas.ts index 26b089ed8..dd9fa3019 100644 --- a/tests/utils/stakeCollatizer/pdas.ts +++ b/tests/utils/stakeCollatizer/pdas.ts @@ -24,3 +24,10 @@ export const deriveStakeHolderStakeAccount = ( programId ); }; + +export const deriveStakeUser = (programId: PublicKey, payer: PublicKey) => { + return PublicKey.findProgramAddressSync( + [Buffer.from("stakeuser", "utf-8"), payer.toBuffer()], + programId + ); +}; From dc6f55aaa197047d7cbde9162d25c24dfd624105 Mon Sep 17 00:00:00 2001 From: Jon Gurary Date: Thu, 12 Sep 2024 15:41:06 -0400 Subject: [PATCH 08/52] WIP integrate spl-single-pool --- Anchor.toml | 5 + package.json | 1 + tests/fixtures/spl_single_pool.so | Bin 0 -> 390632 bytes tests/rootHooks.ts | 77 +++++++++++++++ tests/utils/stake-utils.ts | 10 ++ yarn.lock | 151 ++++++++++++++++++++++++++++++ 6 files changed, 244 insertions(+) create mode 100755 tests/fixtures/spl_single_pool.so diff --git a/Anchor.toml b/Anchor.toml index 92c3dcc51..8b4ed128d 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -11,6 +11,7 @@ liquidity_incentive_program = "Lip1111111111111111111111111111111111111111" marginfi = "2jGhuVUuy3umdzByFx8sNWUAaf5vaeuDm78RDPEnhrMr" mocks = "5XaaR94jBubdbrRrNW7DtRvZeWvLhSHkEGU3jHTEXV3C" staking_collatizer = "65e81uBnLPtUNaFbgzeU4gMwmCbMeeh6GCLDhEVaNNon" +spl_single_pool = "SVSPxpvHdN29nkVg9rPapPNDddN5DipNLRUFhyjFThE" [programs.mainnet] liquidity_incentive_program = "LipsxuAkFkwa4RKNzn51wAsW7Dedzt1RNHMkTkDEZUW" @@ -50,3 +51,7 @@ filename = "tests/fixtures/cloud_bank.json" [[test.validator.account]] address = "8FRFC6MoGGkMFQwngccyu69VnYbzykGeez7ignHVAFSN" filename = "tests/fixtures/localnet_usdc.json" + +[[test.genesis]] +address = "SVSPxpvHdN29nkVg9rPapPNDddN5DipNLRUFhyjFThE" +program = "tests/fixtures/spl_single_pool.so" diff --git a/package.json b/package.json index 9db8b8ceb..e16717cfa 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "@coral-xyz/spl-token": "^0.30.1", "@solana/spl-token": "^0.4.8", "@solana/web3.js": "^1.95.2", + "@solana/spl-single-pool-classic": "^1.0.2", "@mrgnlabs/mrgn-common": "^1.7.0", "@mrgnlabs/marginfi-client-v2": "^3.1.0", "mocha": "^10.2.0", diff --git a/tests/fixtures/spl_single_pool.so b/tests/fixtures/spl_single_pool.so new file mode 100755 index 0000000000000000000000000000000000000000..79c650a190c442a92d87313d65e0901154569482 GIT binary patch literal 390632 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 e`22}s+-*V)*LBcezLNhxdH%onAM4c5^Z#%1 { + console.log("id: " + SinglePoolProgram.programId); + let info = await provider.connection.getAccountInfo( + SinglePoolProgram.programId + ); + console.log("exec: " + info.executable); + console.log("data: " + info.data); + let tx = await SinglePoolProgram.initialize( + // @ts-ignore + provider.connection, + validators[0].voteAccount, + users[0].wallet.publicKey, + true + ); + + // @ts-ignore + await provider.sendAndConfirm(tx, [users[0].wallet]); +}; + +// Attempt to manually build an spl stake pool init since the TS library doesn't expose one... + +// export const createSplStakePool = async ( +// provider: AnchorProvider, +// accounts: any +// ) => { +// console.log("id: " + solanaStakePool.STAKE_POOL_PROGRAM_ID); + +// const keys = [ +// { pubkey: accounts.stakePoolAccount, isSigner: false, isWritable: true }, +// { pubkey: accounts.manager, isSigner: true, isWritable: false }, +// { pubkey: accounts.staker, isSigner: false, isWritable: false }, +// { pubkey: accounts.withdrawAuthority, isSigner: false, isWritable: false }, +// { pubkey: accounts.validatorList, isSigner: false, isWritable: true }, +// { pubkey: accounts.reserveStake, isSigner: false, isWritable: false }, +// { pubkey: accounts.poolTokenMint, isSigner: false, isWritable: false }, +// { pubkey: accounts.feeAccount, isSigner: false, isWritable: false }, +// { +// pubkey: TOKEN_PROGRAM_ID, +// isSigner: false, +// isWritable: false, +// }, +// // Add optional deposit authority if necessary +// ]; + +// const FEE = { numerator: new BN(1), denominator: new BN(50) }; +// const REFERRAL_FEE = 1; + +// const data = Buffer.concat([ +// Buffer.from([0]), // Initialize instruction discriminator +// FEE.numerator.toArrayLike(Buffer, "le", 8), +// FEE.denominator.toArrayLike(Buffer, "le", 8), +// FEE.numerator.toArrayLike(Buffer, "le", 8), // Withdrawal fee +// FEE.denominator.toArrayLike(Buffer, "le", 8), +// FEE.numerator.toArrayLike(Buffer, "le", 8), // Deposit fee +// FEE.denominator.toArrayLike(Buffer, "le", 8), +// Buffer.from([REFERRAL_FEE]), // Referral fee +// Buffer.from(new BN(100).toArrayLike(Buffer, "le", 4)), // max_validators +// ]); + +// const instruction = new TransactionInstruction({ +// keys, +// programId: solanaStakePool.STAKE_POOL_PROGRAM_ID, +// data, +// }); + +// const tx = new Transaction().add(instruction); +// await provider.sendAndConfirm(tx); + +// //await provider.sendAndConfirm(tx, [users[0].wallet]); +// }; \ No newline at end of file diff --git a/tests/utils/stake-utils.ts b/tests/utils/stake-utils.ts index 61f211c73..207694331 100644 --- a/tests/utils/stake-utils.ts +++ b/tests/utils/stake-utils.ts @@ -5,8 +5,11 @@ import { StakeProgram, PublicKey, Connection, + SYSVAR_CLOCK_PUBKEY, } from "@solana/web3.js"; import { mockUser } from "./mocks"; +import { BanksClient } from "solana-bankrun"; +import { BN } from "@coral-xyz/anchor"; /** * Create a stake account for some user @@ -500,3 +503,10 @@ export async function getStakeActivation( inactive, }; } + +export const getEpoch = async (banksClient: BanksClient) => { + let clock = await banksClient.getAccount(SYSVAR_CLOCK_PUBKEY); + // epoch is bytes 16-24 + let epoch = new BN(clock.data.slice(16, 24), 10, "le").toNumber(); + return epoch; +}; diff --git a/yarn.lock b/yarn.lock index b24e9a965..7e4b6263e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9,6 +9,13 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.25.0": + version "7.25.6" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.25.6.tgz#9afc3289f7184d8d7f98b099884c26317b9264d2" + integrity sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ== + dependencies: + regenerator-runtime "^0.14.0" + "@coral-xyz/anchor-errors@^0.30.1": version "0.30.1" resolved "https://registry.yarnpkg.com/@coral-xyz/anchor-errors/-/anchor-errors-0.30.1.tgz#bdfd3a353131345244546876eb4afc0e125bec30" @@ -127,6 +134,48 @@ resolved "https://registry.yarnpkg.com/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz#9299f82874bab9e4c7f9c48d865becbfe8d6907c" integrity sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw== +"@metaplex-foundation/umi-options@^0.8.9": + version "0.8.9" + resolved "https://registry.yarnpkg.com/@metaplex-foundation/umi-options/-/umi-options-0.8.9.tgz#9c9e269d9eee7d055ad6831dcb30a30127dcb0c5" + integrity sha512-jSQ61sZMPSAk/TXn8v8fPqtz3x8d0/blVZXLLbpVbo2/T5XobiI6/MfmlUosAjAUaQl6bHRF8aIIqZEFkJiy4A== + +"@metaplex-foundation/umi-public-keys@^0.8.9": + version "0.8.9" + resolved "https://registry.yarnpkg.com/@metaplex-foundation/umi-public-keys/-/umi-public-keys-0.8.9.tgz#ca7a927c924ed8e28d0f8bb3dc0f2adc1f9011ec" + integrity sha512-CxMzN7dgVGOq9OcNCJe2casKUpJ3RmTVoOvDFyeoTQuK+vkZ1YSSahbqC1iGuHEtKTLSjtWjKvUU6O7zWFTw3Q== + dependencies: + "@metaplex-foundation/umi-serializers-encodings" "^0.8.9" + +"@metaplex-foundation/umi-serializers-core@^0.8.9": + version "0.8.9" + resolved "https://registry.yarnpkg.com/@metaplex-foundation/umi-serializers-core/-/umi-serializers-core-0.8.9.tgz#cd5ae763a59e54dd01f1284f4a6bf4e78e4aab9c" + integrity sha512-WT82tkiYJ0Qmscp7uTj1Hz6aWQPETwaKLAENAUN5DeWghkuBKtuxyBKVvEOuoXerJSdhiAk0e8DWA4cxcTTQ/w== + +"@metaplex-foundation/umi-serializers-encodings@^0.8.9": + version "0.8.9" + resolved "https://registry.yarnpkg.com/@metaplex-foundation/umi-serializers-encodings/-/umi-serializers-encodings-0.8.9.tgz#0f02605ee3e6fbeac1abc4fb267a7cc96ecb4410" + integrity sha512-N3VWLDTJ0bzzMKcJDL08U3FaqRmwlN79FyE4BHj6bbAaJ9LEHjDQ9RJijZyWqTm0jE7I750fU7Ow5EZL38Xi6Q== + dependencies: + "@metaplex-foundation/umi-serializers-core" "^0.8.9" + +"@metaplex-foundation/umi-serializers-numbers@^0.8.9": + version "0.8.9" + resolved "https://registry.yarnpkg.com/@metaplex-foundation/umi-serializers-numbers/-/umi-serializers-numbers-0.8.9.tgz#28c10367f6aebac0276ec1bce81d0d8db54b05de" + integrity sha512-NtBf1fnVNQJHFQjLFzRu2i9GGnigb9hOm/Gfrk628d0q0tRJB7BOM3bs5C61VAs7kJs4yd+pDNVAERJkknQ7Lg== + dependencies: + "@metaplex-foundation/umi-serializers-core" "^0.8.9" + +"@metaplex-foundation/umi-serializers@^0.8.9": + version "0.8.9" + resolved "https://registry.yarnpkg.com/@metaplex-foundation/umi-serializers/-/umi-serializers-0.8.9.tgz#af6c5bb1a3276cbe252fd08e359b305ed80a3343" + integrity sha512-Sve8Etm3zqvLSUfza+MYRkjTnCpiaAFT7VWdqeHzA3n58P0AfT3p74RrZwVt/UFkxI+ln8BslwBDJmwzcPkuHw== + dependencies: + "@metaplex-foundation/umi-options" "^0.8.9" + "@metaplex-foundation/umi-public-keys" "^0.8.9" + "@metaplex-foundation/umi-serializers-core" "^0.8.9" + "@metaplex-foundation/umi-serializers-encodings" "^0.8.9" + "@metaplex-foundation/umi-serializers-numbers" "^0.8.9" + "@mrgnlabs/marginfi-client-v2@^3.1.0": version "3.1.0" resolved "https://registry.yarnpkg.com/@mrgnlabs/marginfi-client-v2/-/marginfi-client-v2-3.1.0.tgz#f380b63aa5ec4fe9f467e3711c5410970c907403" @@ -271,6 +320,19 @@ bs58 "^5.0.0" jito-ts "^3.0.1" +"@solana/addresses@2.0.0-experimental.21e994f": + version "2.0.0-experimental.21e994f" + resolved "https://registry.yarnpkg.com/@solana/addresses/-/addresses-2.0.0-experimental.21e994f.tgz#51509c4e48c3feae573f30a0ad7736d2054b1bdf" + integrity sha512-zmg+ALhjxZApKJKSjeGK7EgMT9NywdvGKlAjyNL2fieiFWp0lRTBmWyjPBCQQGdJjBkayCscq3GQkDF2MhC6fg== + dependencies: + "@metaplex-foundation/umi-serializers" "^0.8.9" + "@solana/assertions" "2.0.0-experimental.21e994f" + +"@solana/assertions@2.0.0-experimental.21e994f": + version "2.0.0-experimental.21e994f" + resolved "https://registry.yarnpkg.com/@solana/assertions/-/assertions-2.0.0-experimental.21e994f.tgz#a67143b41aaf1d810176b943a203f1508f4095df" + integrity sha512-iGOUpOqkqxzQ/xi4Q3YLiBQPASiQ43NYTalmQm99hmOhySRA4+yyQTmMW1PJ8FAm7Zf86cCiYTf19Exa7+DxoQ== + "@solana/buffer-layout-utils@=0.2.0", "@solana/buffer-layout-utils@^0.2.0": version "0.2.0" resolved "https://registry.yarnpkg.com/@solana/buffer-layout-utils/-/buffer-layout-utils-0.2.0.tgz#b45a6cab3293a2eb7597cceb474f229889d875ca" @@ -399,6 +461,23 @@ chalk "^5.3.0" commander "^12.1.0" +"@solana/functional@2.0.0-experimental.21e994f": + version "2.0.0-experimental.21e994f" + resolved "https://registry.yarnpkg.com/@solana/functional/-/functional-2.0.0-experimental.21e994f.tgz#e7ebdc8fcb14a0a2bc7d0f7df8667d171f54a10b" + integrity sha512-FMXFiTA+hsc9FCv0r47oF7njq/K9x7zh0H+To7tpeqwN65LtJPu5BMG7xZY3rn5TrudgKw6XPuIr3ARbI8+IWA== + +"@solana/instructions@2.0.0-experimental.21e994f": + version "2.0.0-experimental.21e994f" + resolved "https://registry.yarnpkg.com/@solana/instructions/-/instructions-2.0.0-experimental.21e994f.tgz#f308fdb671252ff52fcf08366fe7a1800b0d54b1" + integrity sha512-PuJJzvT7wtwE5UcGavUppnfVWnoxL8CPhZBb96HpOaQhQ2JuyhN445bfav5KkaUMCE6ubrVzOEqzrbtygD3aBg== + +"@solana/keys@2.0.0-experimental.21e994f": + version "2.0.0-experimental.21e994f" + resolved "https://registry.yarnpkg.com/@solana/keys/-/keys-2.0.0-experimental.21e994f.tgz#52e9307a0a0055f2bbff23e76b38cb4ba7f75da3" + integrity sha512-Qsm7ARy69PdIuis7TZy8ELyhq0pcRFPXtaZ8vLFUvsukrcWRowiJ8JJs6Q3tA+gQK5vUn9ABp7a7Qs0FHzgbyw== + dependencies: + "@solana/assertions" "2.0.0-experimental.21e994f" + "@solana/options@2.0.0-preview.2": version "2.0.0-preview.2" resolved "https://registry.yarnpkg.com/@solana/options/-/options-2.0.0-preview.2.tgz#13ff008bf43a5056ef9a091dc7bb3f39321e867e" @@ -418,6 +497,33 @@ "@solana/codecs-strings" "2.0.0-preview.4" "@solana/errors" "2.0.0-preview.4" +"@solana/rpc-core@2.0.0-experimental.21e994f": + version "2.0.0-experimental.21e994f" + resolved "https://registry.yarnpkg.com/@solana/rpc-core/-/rpc-core-2.0.0-experimental.21e994f.tgz#294c0ea4d99c1bd6b11bb0c0cc67847adb6f3c3a" + integrity sha512-T7VcTLRi4dsqmpFYdnvcHZFS8Vcgdi6funMUrXcM7ofQqb8vWGJnlX6AX0eIZiVsmoYk5Ki8wW4D6Ul6bXZyZg== + dependencies: + "@metaplex-foundation/umi-serializers" "^0.8.9" + +"@solana/rpc-transport@2.0.0-experimental.21e994f": + version "2.0.0-experimental.21e994f" + resolved "https://registry.yarnpkg.com/@solana/rpc-transport/-/rpc-transport-2.0.0-experimental.21e994f.tgz#2b8c3f97f4853711daaeed03ff0700a1d44aca4a" + integrity sha512-PfGPzRuEodhfLyOD8ZneYQ389SWYgmj1Q/HWQZo8yZMsiAaW/lqCygoW88lecxXKlZF5gJYrBX154kgvGqEM7g== + +"@solana/spl-single-pool-classic@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@solana/spl-single-pool-classic/-/spl-single-pool-classic-1.0.2.tgz#f675cdf39037cd42a3a4690a030cc77c0837e40c" + integrity sha512-kh2D3KElYsJWZIoksCd5dlC9jsKict7WTS+lZvhaGXTarZbjMqhIaiiMTe5oqKgHSNwavoP05VJ8YlTmbTxTLg== + dependencies: + "@solana/spl-single-pool" "1.0.0" + "@solana/web3.js" "^1.91.6" + +"@solana/spl-single-pool@1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@solana/spl-single-pool/-/spl-single-pool-1.0.0.tgz#eec9ca109ad63936b60cab6f2f6f9566e0cd0eeb" + integrity sha512-m2zNzRXcYXibd2n514TQhWM7WkWuCqddNHmxgPDcpSFpwbP9hNUigjGoJeR1khvKjRj5jV+PdiiwBEWi3pExfw== + dependencies: + "@solana/web3.js" "=2.0.0-experimental.21e994f" + "@solana/spl-token-group@^0.0.5": version "0.0.5" resolved "https://registry.yarnpkg.com/@solana/spl-token-group/-/spl-token-group-0.0.5.tgz#f955dcca782031c85e862b2b46878d1bb02db6c2" @@ -452,6 +558,15 @@ dependencies: buffer "^6.0.3" +"@solana/transactions@2.0.0-experimental.21e994f": + version "2.0.0-experimental.21e994f" + resolved "https://registry.yarnpkg.com/@solana/transactions/-/transactions-2.0.0-experimental.21e994f.tgz#48dc6483a1d57e85cd23c88e854239b2ac0bd097" + integrity sha512-DunbTMBzlC7jmTzkFsRm5DhGe+MjaZ8m+SJ7V520mQq+kxrbPrRmI3ikfUVdejg0WaEV4Dy+RwQ5xllsrJ47kA== + dependencies: + "@metaplex-foundation/umi-serializers" "^0.8.9" + "@solana/addresses" "2.0.0-experimental.21e994f" + "@solana/keys" "2.0.0-experimental.21e994f" + "@solana/wallet-adapter-base@^0.9.23": version "0.9.23" resolved "https://registry.yarnpkg.com/@solana/wallet-adapter-base/-/wallet-adapter-base-0.9.23.tgz#3b17c28afd44e173f44f658bf9700fd637e12a11" @@ -470,6 +585,21 @@ "@wallet-standard/base" "^1.0.1" "@wallet-standard/features" "^1.0.3" +"@solana/web3.js@=2.0.0-experimental.21e994f": + version "2.0.0-experimental.21e994f" + resolved "https://registry.yarnpkg.com/@solana/web3.js/-/web3.js-2.0.0-experimental.21e994f.tgz#c5568d88903f63c85de700c03b2acef2217d059f" + integrity sha512-Yy0D57nlNTDm0BhBRIM85Sn52T6vjxpBRRdwE/FOJJmN92n0Qpc4mTAwOPfEqoVpiTcluUBZ4l8FAWxjGCFMgQ== + dependencies: + "@metaplex-foundation/umi-serializers" "^0.8.9" + "@solana/addresses" "2.0.0-experimental.21e994f" + "@solana/functional" "2.0.0-experimental.21e994f" + "@solana/instructions" "2.0.0-experimental.21e994f" + "@solana/keys" "2.0.0-experimental.21e994f" + "@solana/rpc-core" "2.0.0-experimental.21e994f" + "@solana/rpc-transport" "2.0.0-experimental.21e994f" + "@solana/transactions" "2.0.0-experimental.21e994f" + fast-stable-stringify "^1.0.0" + "@solana/web3.js@^1.32.0", "@solana/web3.js@^1.68.0", "@solana/web3.js@^1.90.0", "@solana/web3.js@^1.93.2", "@solana/web3.js@^1.95.2": version "1.95.2" resolved "https://registry.yarnpkg.com/@solana/web3.js/-/web3.js-1.95.2.tgz#6f8a0362fa75886a21550dbec49aad54481463a6" @@ -491,6 +621,27 @@ rpc-websockets "^9.0.2" superstruct "^2.0.2" +"@solana/web3.js@^1.91.6": + version "1.95.3" + resolved "https://registry.yarnpkg.com/@solana/web3.js/-/web3.js-1.95.3.tgz#70b5f4d76823f56b5af6403da51125fffeb65ff3" + integrity sha512-O6rPUN0w2fkNqx/Z3QJMB9L225Ex10PRDH8bTaIUPZXMPV0QP8ZpPvjQnXK+upUczlRgzHzd6SjKIha1p+I6og== + dependencies: + "@babel/runtime" "^7.25.0" + "@noble/curves" "^1.4.2" + "@noble/hashes" "^1.4.0" + "@solana/buffer-layout" "^4.0.1" + agentkeepalive "^4.5.0" + bigint-buffer "^1.1.5" + bn.js "^5.2.1" + borsh "^0.7.0" + bs58 "^4.0.1" + buffer "6.0.3" + fast-stable-stringify "^1.0.0" + jayson "^4.1.1" + node-fetch "^2.7.0" + rpc-websockets "^9.0.2" + superstruct "^2.0.2" + "@solana/web3.js@~1.77.3": version "1.77.4" resolved "https://registry.yarnpkg.com/@solana/web3.js/-/web3.js-1.77.4.tgz#aad8c44a02ced319493308ef765a2b36a9e9fa8c" From ef6c5a236412a30081fd7f0b1e0f149cb0424d7d Mon Sep 17 00:00:00 2001 From: Jon Gurary Date: Fri, 13 Sep 2024 16:07:47 -0400 Subject: [PATCH 09/52] Strip out collatizer program --- Anchor.toml | 7 +- Cargo.lock | 10 -- programs/staking-collatizer/Cargo.toml | 31 ----- programs/staking-collatizer/Xargo.toml | 2 - programs/staking-collatizer/src/constants.rs | 3 - programs/staking-collatizer/src/errors.rs | 7 - .../src/instructions/deposit_stake.rs | 79 ------------ .../src/instructions/init_stakeholder.rs | 120 ------------------ .../src/instructions/init_user.rs | 35 ----- .../src/instructions/mod.rs | 7 - programs/staking-collatizer/src/lib.rs | 31 ----- programs/staking-collatizer/src/macros.rs | 0 programs/staking-collatizer/src/state/mod.rs | 5 - .../src/state/stake_user.rs | 11 -- .../src/state/stakeholder.rs | 29 ----- tests/rootHooks.ts | 110 ++++++---------- tests/s01_usersStake.spec.ts | 57 ++------- tests/s02_initStakeholder.spec.ts | 98 -------------- tests/utils/mocks.ts | 28 +--- tests/utils/spl-staking-utils.ts | 45 +++++++ tests/utils/types.ts | 5 +- 21 files changed, 101 insertions(+), 619 deletions(-) delete mode 100644 programs/staking-collatizer/Cargo.toml delete mode 100644 programs/staking-collatizer/Xargo.toml delete mode 100644 programs/staking-collatizer/src/constants.rs delete mode 100644 programs/staking-collatizer/src/errors.rs delete mode 100644 programs/staking-collatizer/src/instructions/deposit_stake.rs delete mode 100644 programs/staking-collatizer/src/instructions/init_stakeholder.rs delete mode 100644 programs/staking-collatizer/src/instructions/init_user.rs delete mode 100644 programs/staking-collatizer/src/instructions/mod.rs delete mode 100644 programs/staking-collatizer/src/lib.rs delete mode 100644 programs/staking-collatizer/src/macros.rs delete mode 100644 programs/staking-collatizer/src/state/mod.rs delete mode 100644 programs/staking-collatizer/src/state/stake_user.rs delete mode 100644 programs/staking-collatizer/src/state/stakeholder.rs delete mode 100644 tests/s02_initStakeholder.spec.ts create mode 100644 tests/utils/spl-staking-utils.ts diff --git a/Anchor.toml b/Anchor.toml index 8b4ed128d..9a0e33af2 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -10,8 +10,7 @@ skip-lint = false liquidity_incentive_program = "Lip1111111111111111111111111111111111111111" marginfi = "2jGhuVUuy3umdzByFx8sNWUAaf5vaeuDm78RDPEnhrMr" mocks = "5XaaR94jBubdbrRrNW7DtRvZeWvLhSHkEGU3jHTEXV3C" -staking_collatizer = "65e81uBnLPtUNaFbgzeU4gMwmCbMeeh6GCLDhEVaNNon" -spl_single_pool = "SVSPxpvHdN29nkVg9rPapPNDddN5DipNLRUFhyjFThE" +spl_single_pool = "SVSPxpvHdN29nkVg9rPapPNDddN5DipNLRUFhyjFThE" # cloned from solana-labs repo (see below) [programs.mainnet] liquidity_incentive_program = "LipsxuAkFkwa4RKNzn51wAsW7Dedzt1RNHMkTkDEZUW" @@ -52,6 +51,8 @@ filename = "tests/fixtures/cloud_bank.json" address = "8FRFC6MoGGkMFQwngccyu69VnYbzykGeez7ignHVAFSN" filename = "tests/fixtures/localnet_usdc.json" +# To update: +# clone https://github.com/solana-labs/solana-program-library/tree/master and run cargo build-sbf in spl_single_pool [[test.genesis]] -address = "SVSPxpvHdN29nkVg9rPapPNDddN5DipNLRUFhyjFThE" +address = "SVSPxpvHdN29nkVg9rPapPNDddN5DipNLRUFhyjFThE" # spl single pool program program = "tests/fixtures/spl_single_pool.so" diff --git a/Cargo.lock b/Cargo.lock index 4551f12c8..0eaf5addf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6516,16 +6516,6 @@ dependencies = [ "spl-program-error 0.4.1", ] -[[package]] -name = "staking-collatizer" -version = "0.1.0" -dependencies = [ - "anchor-lang 0.30.1", - "anchor-spl 0.30.1", - "bytemuck", - "solana-program", -] - [[package]] name = "static_assertions" version = "1.1.0" diff --git a/programs/staking-collatizer/Cargo.toml b/programs/staking-collatizer/Cargo.toml deleted file mode 100644 index 2f99f12bd..000000000 --- a/programs/staking-collatizer/Cargo.toml +++ /dev/null @@ -1,31 +0,0 @@ -[package] -name = "staking-collatizer" -version = "0.1.0" -description = "Manages control of staked SOL assets to use them as collateral in other programs" -edition = "2021" - -[lib] -crate-type = ["cdylib", "lib"] -name = "staking_collatizer" - -[features] -no-entrypoint = [] -no-idl = [] -no-log-ix-name = [] -cpi = ["no-entrypoint"] -default = ["mainnet-beta"] -idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"] -test-bpf = ["test", "debug"] -test = [] -client = [] -devnet = [] -mainnet-beta = [] -debug = [] -staging = [] - -[dependencies] -solana-program = { workspace = true } -anchor-lang = { workspace = true } -anchor-spl = { workspace = true } - -bytemuck = "1.9.1" diff --git a/programs/staking-collatizer/Xargo.toml b/programs/staking-collatizer/Xargo.toml deleted file mode 100644 index 475fb71ed..000000000 --- a/programs/staking-collatizer/Xargo.toml +++ /dev/null @@ -1,2 +0,0 @@ -[target.bpfel-unknown-unknown.dependencies.std] -features = [] diff --git a/programs/staking-collatizer/src/constants.rs b/programs/staking-collatizer/src/constants.rs deleted file mode 100644 index a0bf6496e..000000000 --- a/programs/staking-collatizer/src/constants.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub const STAKEHOLDER_SEED: &str = "stakeholder"; -pub const STAKEHOLDER_STAKE_ACC_SEED: &str = "stakeacc"; -pub const STAKE_USER_SEED: &str = "stakeuser"; diff --git a/programs/staking-collatizer/src/errors.rs b/programs/staking-collatizer/src/errors.rs deleted file mode 100644 index 7c47ba44e..000000000 --- a/programs/staking-collatizer/src/errors.rs +++ /dev/null @@ -1,7 +0,0 @@ -use anchor_lang::prelude::*; - -#[error_code] -pub enum ErrorCode { - #[msg("Math error")] // 6000 - MathError, -} \ No newline at end of file diff --git a/programs/staking-collatizer/src/instructions/deposit_stake.rs b/programs/staking-collatizer/src/instructions/deposit_stake.rs deleted file mode 100644 index 18ae82f11..000000000 --- a/programs/staking-collatizer/src/instructions/deposit_stake.rs +++ /dev/null @@ -1,79 +0,0 @@ -use anchor_lang::prelude::*; -use solana_program::{ - program::invoke_signed, - stake::state::{Authorized, Lockup, StakeAuthorize}, - system_instruction, -}; - -use crate::{constants::{STAKEHOLDER_SEED, STAKEHOLDER_STAKE_ACC_SEED}, state::StakeHolder}; - -#[derive(Accounts)] -pub struct DepositStake<'info> { - - #[account(mut)] - pub admin: Signer<'info>, - - /// The `user_stake_account`'s authority must also sign. This supports use cases where the admin - /// is moving stake from another wallet they control. - pub stake_authority: Signer<'info>, - - // TODO add user accounts (stakeUser, etc) - - // TODO check admin, stake acc, etc - #[account( - mut - )] - pub stakeholder: AccountLoader<'info, StakeHolder>, - - // /// CHECK: User's stake account (active and delegated to a validator), used by cpi - // #[account(mut)] - // pub user_stake_account: UncheckedAccount<'info>, - - // /// CHECK: Stakeholder's stake account, validated against seeds - // #[account( - // mut, - // seeds = [ - // STAKEHOLDER_STAKE_ACC_SEED.as_bytes(), - // stakeholder.key().as_ref() - // ], - // bump, - // )] - // pub stake_account: UncheckedAccount<'info>, - - /// CHECK: Native stake program, checked against known hardcoded key - #[account( - constraint = stake_program.key() == solana_program::stake::program::ID - )] - pub stake_program: UncheckedAccount<'info>, - /// Sysvar required by the Solana staking program - pub clock: Sysvar<'info, Clock>, - pub system_program: Program<'info, System>, -} - -pub fn deposit_stake(ctx: Context) -> Result<()> { - msg!("Start deposit"); - // let mut stakeholder = ctx.accounts.stakeholder.load_mut()?; - - // let authorize_ix = solana_program::stake::instruction::authorize( - // &ctx.accounts.user_stake_account.key(), // User's stake account - // &ctx.accounts.stake_authority.key(), // Current authorized staker - // &ctx.accounts.stakeholder.key(), // Program stakeholder becomes the new staker - // StakeAuthorize::Staker, - // None - // ); - - // invoke_signed( - // &authorize_ix, - // &[ - // ctx.accounts.user_stake_account.to_account_info(), - // ctx.accounts.clock.to_account_info(), - // ctx.accounts.stake_authority.to_account_info(), - // ctx.accounts.stake_program.to_account_info(), - // ], - // &[], - // )?; - - msg!("Done transfer authority"); - - Ok(()) -} diff --git a/programs/staking-collatizer/src/instructions/init_stakeholder.rs b/programs/staking-collatizer/src/instructions/init_stakeholder.rs deleted file mode 100644 index 651537271..000000000 --- a/programs/staking-collatizer/src/instructions/init_stakeholder.rs +++ /dev/null @@ -1,120 +0,0 @@ -use anchor_lang::prelude::*; -use solana_program::{ - program::invoke_signed, - stake::state::{Authorized, Lockup}, - system_instruction, -}; - -use crate::{constants::{STAKEHOLDER_SEED, STAKEHOLDER_STAKE_ACC_SEED}, state::StakeHolder}; - -#[derive(Accounts)] -pub struct InitStakeHolder<'info> { - /// Pays the account initialization fee - #[account(mut)] - pub payer: Signer<'info>, - - /// CHECK: becomes the admin of the new account, unchecked - pub admin: UncheckedAccount<'info>, - - #[account( - init, - seeds = [ - STAKEHOLDER_SEED.as_bytes(), - vote_account.key().as_ref(), - admin.key().as_ref(), - ], - bump, - payer = payer, - space = 8 + StakeHolder::LEN, - )] - pub stakeholder: AccountLoader<'info, StakeHolder>, - - // TODO remove? - /// CHECK: used by CPI - pub vote_account: UncheckedAccount<'info>, - - /// CHECK: Stakeholder's stake account to be created, validated against seeds - #[account( - mut, - seeds = [ - STAKEHOLDER_STAKE_ACC_SEED.as_bytes(), - stakeholder.key().as_ref() - ], - bump, - )] - pub stake_account: UncheckedAccount<'info>, - - /// CHECK: Native stake program, checked against known hardcoded key - #[account( - constraint = stake_program.key() == solana_program::stake::program::ID - )] - pub stake_program: UncheckedAccount<'info>, - pub rent: Sysvar<'info, Rent>, - pub system_program: Program<'info, System>, -} - -pub fn init_stakeholder(ctx: Context) -> Result<()> { - let mut stakeholder = ctx.accounts.stakeholder.load_init()?; - - stakeholder.key = ctx.accounts.stakeholder.key(); - stakeholder.admin = ctx.accounts.admin.key(); - stakeholder.vote_account = ctx.accounts.vote_account.key(); - stakeholder.stake_account = ctx.accounts.stake_account.key(); - - stakeholder.net_delegation = 0; - - // Create the stake account owned by the Stakeholder PDA - let space = solana_program::stake::state::StakeStateV2::size_of(); - let rent_exempt_lamports = ctx.accounts.rent.minimum_balance(space); - - let create_stake_account_ix = system_instruction::create_account( - &ctx.accounts.payer.key(), - &ctx.accounts.stake_account.key(), - rent_exempt_lamports, - space as u64, - &ctx.accounts.stake_program.key(), - ); - - invoke_signed( - &create_stake_account_ix, - &[ - ctx.accounts.payer.to_account_info(), - ctx.accounts.stake_account.to_account_info(), - ctx.accounts.system_program.to_account_info(), - ], - &[&[ - STAKEHOLDER_STAKE_ACC_SEED.as_bytes(), - ctx.accounts.stakeholder.key().as_ref(), - &[ctx.bumps.stake_account], - ]], - )?; - - // Initialize the stake account for the Stakeholder PDA - let authorized = Authorized { - staker: ctx.accounts.stakeholder.key(), - withdrawer: ctx.accounts.stakeholder.key(), - }; - let lockup = Lockup::default(); - - let init_stake_account_ix = solana_program::stake::instruction::initialize( - &ctx.accounts.stake_account.key(), - &authorized, - &lockup, - ); - - invoke_signed( - &init_stake_account_ix, - &[ - ctx.accounts.stake_account.to_account_info(), - ctx.accounts.stake_program.to_account_info(), - ctx.accounts.rent.to_account_info(), - ], - &[&[ - STAKEHOLDER_STAKE_ACC_SEED.as_bytes(), - ctx.accounts.stakeholder.key().as_ref(), - &[ctx.bumps.stake_account], - ]], - )?; - - Ok(()) -} diff --git a/programs/staking-collatizer/src/instructions/init_user.rs b/programs/staking-collatizer/src/instructions/init_user.rs deleted file mode 100644 index dacc9421d..000000000 --- a/programs/staking-collatizer/src/instructions/init_user.rs +++ /dev/null @@ -1,35 +0,0 @@ -use anchor_lang::prelude::*; - -use crate::{constants::STAKE_USER_SEED, state::StakeUser}; - -#[derive(Accounts)] -pub struct InitUser<'info> { - /// Pays the account initialization fee - #[account(mut)] - pub payer: Signer<'info>, - - // TODO owner seperate from payer - - #[account( - init, - seeds = [ - STAKE_USER_SEED.as_bytes(), - payer.key().as_ref(), - ], - bump, - payer = payer, - space = 8 + StakeUser::LEN, - )] - pub stake_user: AccountLoader<'info, StakeUser>, - - pub rent: Sysvar<'info, Rent>, - pub system_program: Program<'info, System>, -} - -pub fn init_user(ctx: Context) -> Result<()> { - let mut stake_user = ctx.accounts.stake_user.load_init()?; - - stake_user.key = ctx.accounts.stake_user.key(); - - Ok(()) -} diff --git a/programs/staking-collatizer/src/instructions/mod.rs b/programs/staking-collatizer/src/instructions/mod.rs deleted file mode 100644 index e0d239635..000000000 --- a/programs/staking-collatizer/src/instructions/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod deposit_stake; -pub mod init_stakeholder; -pub mod init_user; - -pub use deposit_stake::*; -pub use init_stakeholder::*; -pub use init_user::*; diff --git a/programs/staking-collatizer/src/lib.rs b/programs/staking-collatizer/src/lib.rs deleted file mode 100644 index 4ffa1a9f1..000000000 --- a/programs/staking-collatizer/src/lib.rs +++ /dev/null @@ -1,31 +0,0 @@ -use anchor_lang::prelude::*; - -declare_id!("65e81uBnLPtUNaFbgzeU4gMwmCbMeeh6GCLDhEVaNNon"); - -pub mod constants; -pub mod errors; -pub mod instructions; -pub mod macros; -pub mod state; -// pub mod utils; - -use crate::instructions::*; -// use crate::state::*; -// use errors::*; - -#[program] -pub mod staking_collatizer { - use super::*; - - pub fn init_user(ctx: Context) -> Result<()> { - instructions::init_user::init_user(ctx) - } - - pub fn init_stakeholder(ctx: Context) -> Result<()> { - instructions::init_stakeholder::init_stakeholder(ctx) - } - - pub fn deposit_stake(ctx: Context) -> Result<()> { - instructions::deposit_stake::deposit_stake(ctx) - } -} diff --git a/programs/staking-collatizer/src/macros.rs b/programs/staking-collatizer/src/macros.rs deleted file mode 100644 index e69de29bb..000000000 diff --git a/programs/staking-collatizer/src/state/mod.rs b/programs/staking-collatizer/src/state/mod.rs deleted file mode 100644 index 7107e0fb2..000000000 --- a/programs/staking-collatizer/src/state/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod stake_user; -pub mod stakeholder; - -pub use stake_user::*; -pub use stakeholder::*; diff --git a/programs/staking-collatizer/src/state/stake_user.rs b/programs/staking-collatizer/src/state/stake_user.rs deleted file mode 100644 index 0f3e19da8..000000000 --- a/programs/staking-collatizer/src/state/stake_user.rs +++ /dev/null @@ -1,11 +0,0 @@ -use anchor_lang::prelude::*; - -#[account(zero_copy)] -pub struct StakeUser { - /// The account's own key - pub key: Pubkey, -} - -impl StakeUser { - pub const LEN: usize = std::mem::size_of::(); -} diff --git a/programs/staking-collatizer/src/state/stakeholder.rs b/programs/staking-collatizer/src/state/stakeholder.rs deleted file mode 100644 index 6182583b2..000000000 --- a/programs/staking-collatizer/src/state/stakeholder.rs +++ /dev/null @@ -1,29 +0,0 @@ -use anchor_lang::prelude::*; - -/// A PDA that holds the assets delegated to this program and tracks various information about its holdings. -#[account(zero_copy)] -pub struct StakeHolder { - /// The account's own key, a pda of `vote_account`, `admin, and b"stakeholder" - pub key: Pubkey, - - /// Currently unused - pub admin: Pubkey, - - /// The validator's vote account where stake is delegated - pub vote_account: Pubkey, - - /// The stake account where held stake is stored - pub stake_account: Pubkey, - - // TODO also track program-wide (or admin-wide) delegation on another struct - /// Net SOL controlled by this account - /// * In SOL, in native decimals (lamports) - pub net_delegation: u64, - - /// Reserved for future use - pub reserved0: [u8; 512], -} - -impl StakeHolder { - pub const LEN: usize = std::mem::size_of::(); -} diff --git a/tests/rootHooks.ts b/tests/rootHooks.ts index 7700f1e04..79b250574 100644 --- a/tests/rootHooks.ts +++ b/tests/rootHooks.ts @@ -16,18 +16,23 @@ import { PublicKey, StakeProgram, SystemProgram, - SYSVAR_EPOCH_SCHEDULE_PUBKEY, SYSVAR_STAKE_HISTORY_PUBKEY, Transaction, VoteInit, VoteProgram, } from "@solana/web3.js"; import { setupPythOracles } from "./utils/pyth_mocks"; -import { StakingCollatizer } from "../target/types/staking_collatizer"; import { BankrunProvider } from "anchor-bankrun"; import { BanksClient, ProgramTestContext, startAnchor } from "solana-bankrun"; import path from "path"; -import { SinglePoolProgram } from "@solana/spl-single-pool-classic"; +import { + findPoolAddress, + SinglePoolProgram, +} from "@solana/spl-single-pool-classic"; +import { SINGLE_POOL_PROGRAM_ID } from "./utils/types"; +import { assertKeysEqual } from "./utils/genericTests"; +import { assert } from "chai"; +import { decodeSinglePool } from "./utils/spl-staking-utils"; export const ecosystem: Ecosystem = getGenericEcosystem(); export let oracles: Oracles = undefined; @@ -53,7 +58,7 @@ export const bankKeypairA = Keypair.generate(); export let bankrunContext: ProgramTestContext; export let bankRunProvider: BankrunProvider; -export let bankrunProgram: Program; +export let bankrunProgram: Program; export let banksClient: BanksClient; /** keys copied into the bankrun instance */ let copyKeys: PublicKey[] = []; @@ -61,8 +66,6 @@ let copyKeys: PublicKey[] = []; export const mochaHooks = { beforeAll: async () => { const mrgnProgram = workspace.Marginfi as Program; - const collatProgram = - workspace.StakingCollatizer as Program; const provider = AnchorProvider.local(); const wallet = provider.wallet as Wallet; @@ -106,7 +109,6 @@ export const mochaHooks = { const setupUserOptions: SetupTestUserOptions = { marginProgram: mrgnProgram, - collatizerProgram: collatProgram, forceWallet: undefined, // If mints are created, typically create the ATA too, otherwise pass undefined... wsolMint: undefined, @@ -165,7 +167,7 @@ export const mochaHooks = { } addValidator(validator); - const splStakePool = await createSplStakePool(provider); + const splStakePool = await createSplStakePool(provider, validator); if (verbose) { console.log("init stake pool"); } @@ -186,7 +188,7 @@ export const mochaHooks = { bankrunContext = await startAnchor(path.resolve(), [], addedAccounts); bankRunProvider = new BankrunProvider(bankrunContext); - bankrunProgram = new Program(collatProgram.idl, bankRunProvider); + bankrunProgram = new Program(mrgnProgram.idl, bankRunProvider); banksClient = bankrunContext.banksClient; if (verbose) { @@ -261,78 +263,44 @@ export const createValidator = async ( authorizedVoter: authorizedVoter.publicKey, authorizedWithdrawer: authorizedWithdrawer, voteAccount: voteAccount.publicKey, + splPool: PublicKey.default, }; return validator; }; -export const createSplStakePool = async (provider: AnchorProvider) => { - console.log("id: " + SinglePoolProgram.programId); - let info = await provider.connection.getAccountInfo( - SinglePoolProgram.programId - ); - console.log("exec: " + info.executable); - console.log("data: " + info.data); +/** + * + * @param provider + * @param validator - mutated, adds the spl key + */ +export const createSplStakePool = async ( + provider: AnchorProvider, + validator: Validator +) => { let tx = await SinglePoolProgram.initialize( - // @ts-ignore + // @ts-ignore // Doesn't matter provider.connection, - validators[0].voteAccount, + validator.voteAccount, users[0].wallet.publicKey, true ); - // @ts-ignore + // @ts-ignore // Doesn't matter await provider.sendAndConfirm(tx, [users[0].wallet]); -}; -// Attempt to manually build an spl stake pool init since the TS library doesn't expose one... - -// export const createSplStakePool = async ( -// provider: AnchorProvider, -// accounts: any -// ) => { -// console.log("id: " + solanaStakePool.STAKE_POOL_PROGRAM_ID); - -// const keys = [ -// { pubkey: accounts.stakePoolAccount, isSigner: false, isWritable: true }, -// { pubkey: accounts.manager, isSigner: true, isWritable: false }, -// { pubkey: accounts.staker, isSigner: false, isWritable: false }, -// { pubkey: accounts.withdrawAuthority, isSigner: false, isWritable: false }, -// { pubkey: accounts.validatorList, isSigner: false, isWritable: true }, -// { pubkey: accounts.reserveStake, isSigner: false, isWritable: false }, -// { pubkey: accounts.poolTokenMint, isSigner: false, isWritable: false }, -// { pubkey: accounts.feeAccount, isSigner: false, isWritable: false }, -// { -// pubkey: TOKEN_PROGRAM_ID, -// isSigner: false, -// isWritable: false, -// }, -// // Add optional deposit authority if necessary -// ]; - -// const FEE = { numerator: new BN(1), denominator: new BN(50) }; -// const REFERRAL_FEE = 1; - -// const data = Buffer.concat([ -// Buffer.from([0]), // Initialize instruction discriminator -// FEE.numerator.toArrayLike(Buffer, "le", 8), -// FEE.denominator.toArrayLike(Buffer, "le", 8), -// FEE.numerator.toArrayLike(Buffer, "le", 8), // Withdrawal fee -// FEE.denominator.toArrayLike(Buffer, "le", 8), -// FEE.numerator.toArrayLike(Buffer, "le", 8), // Deposit fee -// FEE.denominator.toArrayLike(Buffer, "le", 8), -// Buffer.from([REFERRAL_FEE]), // Referral fee -// Buffer.from(new BN(100).toArrayLike(Buffer, "le", 4)), // max_validators -// ]); - -// const instruction = new TransactionInstruction({ -// keys, -// programId: solanaStakePool.STAKE_POOL_PROGRAM_ID, -// data, -// }); - -// const tx = new Transaction().add(instruction); -// await provider.sendAndConfirm(tx); - -// //await provider.sendAndConfirm(tx, [users[0].wallet]); -// }; \ No newline at end of file + // Note: you can import the id from @solana/spl-single-pool (the classic version doesn't have it) + const poolKey = await findPoolAddress( + SINGLE_POOL_PROGRAM_ID, + validator.voteAccount + ); + validator.splPool = poolKey; + + const poolAcc = await provider.connection.getAccountInfo(poolKey); + // Rudimentary validation that this account now exists and is owned by the single pool program + assertKeysEqual(poolAcc.owner, SINGLE_POOL_PROGRAM_ID); + assert.equal(poolAcc.executable, false); + + const pool = decodeSinglePool(poolAcc.data); + assertKeysEqual(pool.voteAccountAddress, validator.voteAccount); +}; diff --git a/tests/s01_usersStake.spec.ts b/tests/s01_usersStake.spec.ts index ca2de2a11..4c3282d15 100644 --- a/tests/s01_usersStake.spec.ts +++ b/tests/s01_usersStake.spec.ts @@ -1,40 +1,27 @@ +import { BN, Program, workspace } from "@coral-xyz/anchor"; +import { LAMPORTS_PER_SOL, PublicKey, Transaction } from "@solana/web3.js"; import { - AnchorProvider, - BN, - getProvider, - Program, - workspace, -} from "@coral-xyz/anchor"; -import { - LAMPORTS_PER_SOL, - PublicKey, - SYSVAR_CLOCK_PUBKEY, - Transaction, -} from "@solana/web3.js"; -import { - bankrunProgram as bankrunProgram, bankrunContext, bankRunProvider, users, validators, verbose, banksClient, + bankrunProgram, } from "./rootHooks"; -import { StakingCollatizer } from "../target/types/staking_collatizer"; import { createStakeAccount, delegateStake, + getEpoch, getStakeAccount, getStakeActivation, } from "./utils/stake-utils"; import { assertBNEqual, assertKeysEqual } from "./utils/genericTests"; import { u64MAX_BN } from "./utils/types"; - -import { deriveStakeUser } from "./utils/stakeCollatizer/pdas"; +import { SinglePoolProgram } from "@solana/spl-single-pool-classic"; describe("User stakes some native and creates an account", () => { - const program = workspace.StakingCollatizer as Program; - + /** Users's validator 0 stake account */ let stakeAccount: PublicKey; it("(user 0) Create user stake account and stake to validator", async () => { @@ -66,9 +53,7 @@ describe("User stakes some native and creates an account", () => { console.log("user 0 delegated to " + validators[0].voteAccount); } - let clock = await banksClient.getAccount(SYSVAR_CLOCK_PUBKEY); - // epoch is bytes 16-24 - let epochBefore = new BN(clock.data.slice(16, 24), 10, "le").toNumber(); + let epochBefore = await getEpoch(banksClient); const stakeAccountInfo = await bankRunProvider.connection.getAccountInfo( stakeAccount ); @@ -106,9 +91,7 @@ describe("User stakes some native and creates an account", () => { it("Advance the epoch", async () => { bankrunContext.warpToEpoch(1n); - let clock = await banksClient.getAccount(SYSVAR_CLOCK_PUBKEY); - // epoch is bytes 16-24 - let epoch = new BN(clock.data.slice(16, 24), 10, "le").toNumber(); + let epoch = await getEpoch(banksClient); if (verbose) { console.log("Warped to epoch: " + epoch); } @@ -130,28 +113,4 @@ describe("User stakes some native and creates an account", () => { ); } }); - - it("(user 0) Init user account - happy path", async () => { - let tx = new Transaction(); - - tx.add( - await program.methods - .initUser() - .accounts({ - payer: users[0].wallet.publicKey, - }) - .instruction() - ); - - const [stakeUserKey] = deriveStakeUser( - program.programId, - users[0].wallet.publicKey - ); - tx.recentBlockhash = bankrunContext.lastBlockhash; - tx.sign(users[0].wallet); - await banksClient.processTransaction(tx); - - let userAcc = await bankrunProgram.account.stakeUser.fetch(stakeUserKey); - assertKeysEqual(userAcc.key, stakeUserKey); - }); }); diff --git a/tests/s02_initStakeholder.spec.ts b/tests/s02_initStakeholder.spec.ts deleted file mode 100644 index 11570390b..000000000 --- a/tests/s02_initStakeholder.spec.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { - AnchorProvider, - getProvider, - Program, - Wallet, - workspace, -} from "@coral-xyz/anchor"; -import { PublicKey, StakeProgram, Transaction } from "@solana/web3.js"; -import { - bankrunProgram, - bankrunContext, - groupAdmin, - validators, - banksClient, - users, -} from "./rootHooks"; -import { StakingCollatizer } from "../target/types/staking_collatizer"; -import { assertBNEqual, assertKeysEqual } from "./utils/genericTests"; - -import { - deriveStakeHolder, - deriveStakeHolderStakeAccount, -} from "./utils/stakeCollatizer/pdas"; - -describe("Create a stake holder for validator", () => { - const program = workspace.StakingCollatizer as Program; - const provider = getProvider() as AnchorProvider; - const wallet = provider.wallet as Wallet; - - it("(admin) Create stake holder for validator 0", async () => { - let tx = new Transaction(); - - tx.add( - await program.methods - .initStakeholder() - .accounts({ - payer: groupAdmin.wallet.publicKey, - admin: groupAdmin.wallet.publicKey, - voteAccount: validators[0].voteAccount, - stakeProgram: StakeProgram.programId, - }) - .instruction() - ); - - tx.recentBlockhash = bankrunContext.lastBlockhash; - tx.sign(groupAdmin.wallet); - await banksClient.processTransaction(tx); - - const [stakeholderKey] = deriveStakeHolder( - program.programId, - validators[0].voteAccount, - groupAdmin.wallet.publicKey - ); - const [stakeholderStakeAcc] = deriveStakeHolderStakeAccount( - program.programId, - stakeholderKey - ); - let sh = await bankrunProgram.account.stakeHolder.fetch(stakeholderKey); - assertKeysEqual(sh.key, stakeholderKey); - assertKeysEqual(sh.admin, groupAdmin.wallet.publicKey); - assertKeysEqual(sh.voteAccount, validators[0].voteAccount); - assertKeysEqual(sh.stakeAccount, stakeholderStakeAcc); - assertBNEqual(sh.netDelegation, 0); - }); - - // TODO move to new file - it("(user 0) deposit stake to holder in exchange for collateral", async () => { - let tx = new Transaction(); - - let stakeAcc = users[0].accounts.get("v0_stakeacc"); - console.log("read stake acc: " + stakeAcc); - const [stakeholderKey] = deriveStakeHolder( - program.programId, - validators[0].voteAccount, - groupAdmin.wallet.publicKey - ); - - tx.add( - await program.methods - .depositStake() - .accounts({ - admin: users[0].wallet.publicKey, - stakeAuthority: users[0].wallet.publicKey, - stakeholder: stakeholderKey, - // userStakeAccount: stakeAcc, - stakeProgram: StakeProgram.programId, - }) - .instruction() - ); - - tx.recentBlockhash = bankrunContext.lastBlockhash; - tx.sign(users[0].wallet); - let res = await banksClient.processTransaction(tx); - console.log("res " + res); - - - }); -}); diff --git a/tests/utils/mocks.ts b/tests/utils/mocks.ts index e9ec6dd65..b88de76d0 100644 --- a/tests/utils/mocks.ts +++ b/tests/utils/mocks.ts @@ -16,7 +16,6 @@ import { } from "@solana/web3.js"; import { Marginfi } from "../../target/types/marginfi"; import { Mocks } from "../../target/types/mocks"; -import { StakingCollatizer } from "../../target/types/staking_collatizer"; export type Ecosystem = { /** A generic wsol mint with 9 decimals (same as native) */ @@ -98,11 +97,6 @@ export type mockUser = { usdcAccount: PublicKey; /** A marginfi program that uses the user's wallet */ userMarginProgram: Program | undefined; - /** - * A staking collatizer program that uses the user's wallet. - * * NOTE: When testing, you will most likely use BankRun's client instead! - */ - userCollatizerProgram: Program | undefined; /** A map to store arbitrary accounts related to the user using a string key */ accounts: Map; }; @@ -112,7 +106,6 @@ export type mockUser = { */ export interface SetupTestUserOptions { marginProgram: Program; - collatizerProgram: Program; /** Force the mock user to use this keypair */ forceWallet: Keypair; wsolMint: PublicKey; @@ -219,9 +212,6 @@ export const setupTestUser = async ( userMarginProgram: options.marginProgram ? getUserMarginfiProgram(options.marginProgram, userWalletKeypair) : undefined, - userCollatizerProgram: options.marginProgram - ? getUserCollatizerProgram(options.collatizerProgram, userWalletKeypair) - : undefined, accounts: new Map(), }; return user; @@ -244,23 +234,6 @@ export const getUserMarginfiProgram = ( return userProgram; }; -/** - * Generates a mock program that can sign transactions as the user's wallet - * @param program - * @param userWallet - * @returns - */ -export const getUserCollatizerProgram = ( - program: Program, - userWallet: Keypair | Wallet -) => { - const wallet = - userWallet instanceof Keypair ? new Wallet(userWallet) : userWallet; - const provider = new AnchorProvider(program.provider.connection, wallet, {}); - const userProgram = new Program(program.idl, provider); - return userProgram; -}; - /** * Ixes to create a mint, the payer gains the Mint Tokens/Freeze authority * @param payer - pays account init fees, must sign, gains mint/freeze authority @@ -381,4 +354,5 @@ export type Validator = { authorizedVoter: PublicKey; authorizedWithdrawer: PublicKey; voteAccount: PublicKey; + splPool: PublicKey; }; diff --git a/tests/utils/spl-staking-utils.ts b/tests/utils/spl-staking-utils.ts new file mode 100644 index 000000000..f927f1bec --- /dev/null +++ b/tests/utils/spl-staking-utils.ts @@ -0,0 +1,45 @@ +import { PublicKey } from "@solana/web3.js"; + +export enum SinglePoolAccountType { + Uninitialized = 0, + Pool = 1, +} + +export type SinglePool = { + accountType: SinglePoolAccountType; + voteAccountAddress: PublicKey; +}; + +const decodeSinglePoolAccountType = (buffer: Buffer, offset: number) => { + const accountType = buffer.readUInt8(offset); + if (accountType === 0) { + return SinglePoolAccountType.Uninitialized; + } else if (accountType === 1) { + return SinglePoolAccountType.Pool; + } else { + throw new Error("Unknown SinglePoolAccountType"); + } +}; + +/** + * Decode an spl single pool from buffer. + * + * Get the data buffer with `const data = (await provider.connection.getAccountInfo(poolKey)).data;` + * and note that there is no discriminator (i.e. pass data directly without additional slicing) + */ +export const decodeSinglePool = (buffer: Buffer) => { + let offset = 0; + + const accountType = decodeSinglePoolAccountType(buffer, offset); + offset += 1; + + const voteAccountAddress = new PublicKey( + buffer.subarray(offset, offset + 32) + ); + offset += 32; + + return { + accountType, + voteAccountAddress, + }; +}; diff --git a/tests/utils/types.ts b/tests/utils/types.ts index 33fbd1096..c95e12584 100644 --- a/tests/utils/types.ts +++ b/tests/utils/types.ts @@ -6,7 +6,10 @@ import BN from "bn.js"; export const I80F48_ZERO = bigNumberToWrappedI80F48(0); export const I80F48_ONE = bigNumberToWrappedI80F48(1); /** Equivalent in value to u64::MAX in Rust */ -export const u64MAX_BN= new BN("18446744073709551615"); +export const u64MAX_BN = new BN("18446744073709551615"); +export const SINGLE_POOL_PROGRAM_ID = new PublicKey( + "SVSPxpvHdN29nkVg9rPapPNDddN5DipNLRUFhyjFThE" +); export type RiskTier = { collateral: {} } | { isolated: {} }; From b37bc2626e06dad132bb9b64cf4e60faa84311d3 Mon Sep 17 00:00:00 2001 From: Jon Gurary Date: Fri, 13 Sep 2024 17:21:15 -0400 Subject: [PATCH 10/52] WIP rewrite spl single token deposit --- Anchor.toml | 4 +-- tests/rootHooks.ts | 36 ++++++++++++++++++++--- tests/s01_usersStake.spec.ts | 50 ++++++++++++++++++++++++++++---- tests/utils/mocks.ts | 4 +++ tests/utils/spl-staking-utils.ts | 38 +++++++++++++++++++++++- 5 files changed, 119 insertions(+), 13 deletions(-) diff --git a/Anchor.toml b/Anchor.toml index 9a0e33af2..43768cd3a 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -26,8 +26,8 @@ wallet = "~/.config/solana/id.json" [scripts] # test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/*.spec.ts --exit --require tests/rootHooks.ts" -# Staking Collatizer only -test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/s*.spec.ts --exit --require tests/rootHooks.ts" +# Staking Collatizer only (remove RUST_LOG= to see bankRun logs) +test = "RUST_LOG= yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/s*.spec.ts --exit --require tests/rootHooks.ts" [test] startup_wait = 5000 diff --git a/tests/rootHooks.ts b/tests/rootHooks.ts index 79b250574..aed47ef5b 100644 --- a/tests/rootHooks.ts +++ b/tests/rootHooks.ts @@ -27,6 +27,8 @@ import { BanksClient, ProgramTestContext, startAnchor } from "solana-bankrun"; import path from "path"; import { findPoolAddress, + findPoolMintAddress, + findPoolStakeAuthorityAddress, SinglePoolProgram, } from "@solana/spl-single-pool-classic"; import { SINGLE_POOL_PROGRAM_ID } from "./utils/types"; @@ -167,9 +169,14 @@ export const mochaHooks = { } addValidator(validator); - const splStakePool = await createSplStakePool(provider, validator); + let { poolKey, poolMintKey, poolAuthority } = await createSplStakePool( + provider, + validator + ); if (verbose) { - console.log("init stake pool"); + console.log(" spl stake pool: " + poolKey); + console.log(" spl stake mint: " + poolMintKey); + console.log(" spl pool auth: " + poolAuthority); } } @@ -218,6 +225,8 @@ const addUser = (user: MockUser) => { /** * Create a mock validator with given vote/withdraw authority * + * * Note: Spl Pool fields are initialized to pubkey default. + * * @param provider * @param authorizedVoter - also pays init fees * @param authorizedWithdrawer - also pays init fees @@ -264,15 +273,17 @@ export const createValidator = async ( authorizedWithdrawer: authorizedWithdrawer, voteAccount: voteAccount.publicKey, splPool: PublicKey.default, + splMint: PublicKey.default, + splAuthority: PublicKey.default, }; return validator; }; /** - * + * Create a single-validator spl stake pool. * @param provider - * @param validator - mutated, adds the spl key + * @param validator - mutated, adds the spl keys */ export const createSplStakePool = async ( provider: AnchorProvider, @@ -303,4 +314,21 @@ export const createSplStakePool = async ( const pool = decodeSinglePool(poolAcc.data); assertKeysEqual(pool.voteAccountAddress, validator.voteAccount); + + const poolMintKey = await findPoolMintAddress( + SINGLE_POOL_PROGRAM_ID, + poolKey + ); + validator.splMint = poolMintKey; + copyKeys.push(poolMintKey); + + const poolAuthority = await findPoolStakeAuthorityAddress( + SINGLE_POOL_PROGRAM_ID, + poolKey + ); + validator.splAuthority = poolAuthority; + // Note: accounts that do not exist (blank PDAs) cannot be pushed) + // copyKeys.push(poolAuthority); + + return { poolKey, poolMintKey, poolAuthority }; }; diff --git a/tests/s01_usersStake.spec.ts b/tests/s01_usersStake.spec.ts index 4c3282d15..511ad72f0 100644 --- a/tests/s01_usersStake.spec.ts +++ b/tests/s01_usersStake.spec.ts @@ -19,15 +19,18 @@ import { import { assertBNEqual, assertKeysEqual } from "./utils/genericTests"; import { u64MAX_BN } from "./utils/types"; import { SinglePoolProgram } from "@solana/spl-single-pool-classic"; +import { getAssociatedTokenAddressSync } from "@mrgnlabs/mrgn-common"; +import { depositToSinglePoolIxes } from "./utils/spl-staking-utils"; describe("User stakes some native and creates an account", () => { /** Users's validator 0 stake account */ let stakeAccount: PublicKey; + const stake = 10; it("(user 0) Create user stake account and stake to validator", async () => { let { createTx, stakeAccountKeypair } = createStakeAccount( users[0], - 10 * LAMPORTS_PER_SOL + stake * LAMPORTS_PER_SOL ); createTx.recentBlockhash = bankrunContext.lastBlockhash; createTx.sign(users[0].wallet, stakeAccountKeypair); @@ -36,7 +39,13 @@ describe("User stakes some native and creates an account", () => { if (verbose) { console.log("Create stake account: " + stakeAccount); - console.log(" Stake: " + 10 / LAMPORTS_PER_SOL + " SOL"); + console.log( + " Stake: " + + stake + + " SOL (" + + (stake * LAMPORTS_PER_SOL).toLocaleString() + + ") in native" + ); } users[0].accounts.set("v0_stakeacc", stakeAccountKeypair.publicKey); @@ -96,7 +105,7 @@ describe("User stakes some native and creates an account", () => { console.log("Warped to epoch: " + epoch); } - const stakeStatusAfter1 = await getStakeActivation( + const stakeStatusAfter = await getStakeActivation( bankRunProvider.connection, stakeAccount, epoch @@ -105,12 +114,41 @@ describe("User stakes some native and creates an account", () => { console.log("It is now epoch: " + epoch); console.log( "Stake active: " + - stakeStatusAfter1.active.toLocaleString() + + stakeStatusAfter.active.toLocaleString() + " inactive " + - stakeStatusAfter1.inactive.toLocaleString() + + stakeStatusAfter.inactive.toLocaleString() + " status: " + - stakeStatusAfter1.status + stakeStatusAfter.status ); } }); + + it("(user 0) Deposit stake to the LST pool", async () => { + console.log(" stake acc " + validators[0].splPool); + console.log(" wallet " + users[0].wallet.publicKey); + console.log(" stake acc " + users[0].accounts.get("v0_stakeacc")); + // TODO this doesn't work with banks client, rewrite from source (ew) + // const tx = await SinglePoolProgram.deposit({ + // // @ts-ignore // Doesn't matter + // connection: bankRunProvider.connection, + // pool: validators[0].splPool, + // userWallet: users[0].wallet.publicKey, + // userStakeAccount: users[0].accounts.get("v0_stakeacc"), + // // depositFromDefaultAccount: false, + // }); + + let tx = new Transaction(); + const ixes = await depositToSinglePoolIxes( + bankRunProvider.connection, + users[0].wallet.publicKey, + validators[0].splMint, + verbose + ); + tx.add(...ixes); + + tx.recentBlockhash = bankrunContext.lastBlockhash; + tx.sign(users[0].wallet); + // @ts-ignore // Doesn't matter + await banksClient.processTransaction(tx); + }); }); diff --git a/tests/utils/mocks.ts b/tests/utils/mocks.ts index b88de76d0..a9ef271cc 100644 --- a/tests/utils/mocks.ts +++ b/tests/utils/mocks.ts @@ -355,4 +355,8 @@ export type Validator = { authorizedWithdrawer: PublicKey; voteAccount: PublicKey; splPool: PublicKey; + /** spl pool's mint for the LST */ + splMint: PublicKey; + /** spl pool's authority for LST management (a PDA automatically created on init) */ + splAuthority: PublicKey; }; diff --git a/tests/utils/spl-staking-utils.ts b/tests/utils/spl-staking-utils.ts index f927f1bec..21752208b 100644 --- a/tests/utils/spl-staking-utils.ts +++ b/tests/utils/spl-staking-utils.ts @@ -1,4 +1,8 @@ -import { PublicKey } from "@solana/web3.js"; +import { + createAssociatedTokenAccountInstruction, + getAssociatedTokenAddressSync, +} from "@solana/spl-token"; +import { Connection, PublicKey, TransactionInstruction } from "@solana/web3.js"; export enum SinglePoolAccountType { Uninitialized = 0, @@ -43,3 +47,35 @@ export const decodeSinglePool = (buffer: Buffer) => { voteAccountAddress, }; }; + +// See `https://www.npmjs.com/package/@solana/spl-single-pool` transactions.ts for the original + +export const depositToSinglePoolIxes = async ( + connection: Connection, + userWallet: PublicKey, + splMint: PublicKey, + verbose: boolean = false +) => { + const ixes: TransactionInstruction[] = []; + const lstAta = getAssociatedTokenAddressSync(splMint, userWallet); + try { + await connection.getAccountInfo(lstAta); + if (verbose) { + console.log("Existing LST ata at: " + lstAta); + } + } catch (err) { + if (verbose) { + console.log("Did not find token account, creating: " + lstAta); + } + ixes.push( + createAssociatedTokenAccountInstruction( + userWallet, + lstAta, + userWallet, + splMint + ) + ); + } + + return ixes; +}; From ac09ed9291cb8e0280982cacf27e52a2456a6724 Mon Sep 17 00:00:00 2001 From: Jon Gurary Date: Fri, 13 Sep 2024 19:19:50 -0400 Subject: [PATCH 11/52] Slot warping wizard magic --- tests/rootHooks.ts | 2 +- tests/s01_usersStake.spec.ts | 75 ++++++++++++++++++++++---------- tests/utils/spl-staking-utils.ts | 41 ++++++++++++++++- tests/utils/stake-utils.ts | 11 +++-- 4 files changed, 99 insertions(+), 30 deletions(-) diff --git a/tests/rootHooks.ts b/tests/rootHooks.ts index aed47ef5b..7ca0403a2 100644 --- a/tests/rootHooks.ts +++ b/tests/rootHooks.ts @@ -180,7 +180,7 @@ export const mochaHooks = { } } - copyKeys.push(StakeProgram.programId); + // copyKeys.push(StakeProgram.programId); copyKeys.push(SYSVAR_STAKE_HISTORY_PUBKEY); const accountKeys = copyKeys; diff --git a/tests/s01_usersStake.spec.ts b/tests/s01_usersStake.spec.ts index 511ad72f0..b2225bba7 100644 --- a/tests/s01_usersStake.spec.ts +++ b/tests/s01_usersStake.spec.ts @@ -1,5 +1,10 @@ import { BN, Program, workspace } from "@coral-xyz/anchor"; -import { LAMPORTS_PER_SOL, PublicKey, Transaction } from "@solana/web3.js"; +import { + LAMPORTS_PER_SOL, + PublicKey, + SystemProgram, + Transaction, +} from "@solana/web3.js"; import { bankrunContext, bankRunProvider, @@ -12,7 +17,7 @@ import { import { createStakeAccount, delegateStake, - getEpoch, + getEpochAndSlot, getStakeAccount, getStakeActivation, } from "./utils/stake-utils"; @@ -44,7 +49,7 @@ describe("User stakes some native and creates an account", () => { stake + " SOL (" + (stake * LAMPORTS_PER_SOL).toLocaleString() + - ") in native" + " in native)" ); } users[0].accounts.set("v0_stakeacc", stakeAccountKeypair.publicKey); @@ -62,7 +67,7 @@ describe("User stakes some native and creates an account", () => { console.log("user 0 delegated to " + validators[0].voteAccount); } - let epochBefore = await getEpoch(banksClient); + let { epoch, slot } = await getEpochAndSlot(banksClient); const stakeAccountInfo = await bankRunProvider.connection.getAccountInfo( stakeAccount ); @@ -76,16 +81,16 @@ describe("User stakes some native and creates an account", () => { new BN(delegation.stake.toString()), new BN(10 * LAMPORTS_PER_SOL).sub(rent) ); - assertBNEqual(new BN(delegation.activationEpoch.toString()), epochBefore); + assertBNEqual(new BN(delegation.activationEpoch.toString()), epoch); assertBNEqual(new BN(delegation.deactivationEpoch.toString()), u64MAX_BN); const stakeStatusBefore = await getStakeActivation( bankRunProvider.connection, stakeAccount, - epochBefore + epoch ); if (verbose) { - console.log("It is now epoch: " + epochBefore); + console.log("It is now epoch: " + epoch + " slot " + slot); console.log( "Stake active: " + stakeStatusBefore.active.toLocaleString() + @@ -97,21 +102,26 @@ describe("User stakes some native and creates an account", () => { } }); + // User delegates to stake pool (this works fine) + it("Advance the epoch", async () => { bankrunContext.warpToEpoch(1n); - let epoch = await getEpoch(banksClient); + let { epoch: epochAfterWarp, slot: slotAfterWarp } = await getEpochAndSlot( + banksClient + ); if (verbose) { - console.log("Warped to epoch: " + epoch); + console.log( + "Warped to epoch: " + epochAfterWarp + " slot " + slotAfterWarp + ); } const stakeStatusAfter = await getStakeActivation( bankRunProvider.connection, stakeAccount, - epoch + epochAfterWarp ); if (verbose) { - console.log("It is now epoch: " + epoch); console.log( "Stake active: " + stakeStatusAfter.active.toLocaleString() + @@ -121,27 +131,44 @@ describe("User stakes some native and creates an account", () => { stakeStatusAfter.status ); } + + // Advance a few slots and send some dummy txes to end the rewards period + + // NOTE: ALL STAKE PROGRAM IXES ARE DISABLED DURING THE REWARDS PERIOD. THIS MUST OCCUR OR THE + // STAKE PROGRAM CANNOT RUN + + for (let i = 0; i < 100; i++) { + bankrunContext.warpToSlot(BigInt(i + slotAfterWarp + 1)); + const dummyTx = new Transaction(); + dummyTx.add( + SystemProgram.transfer({ + fromPubkey: users[0].wallet.publicKey, + toPubkey: bankrunProgram.provider.publicKey, + lamports: i, + }) + ); + dummyTx.recentBlockhash = bankrunContext.lastBlockhash; + dummyTx.sign(users[0].wallet); + await banksClient.processTransaction(dummyTx); + if (i % 10 == 0) { + console.log("Dummy ix: " + i); + let { epoch, slot } = await getEpochAndSlot(banksClient); + console.log("is now epoch: " + epoch + " slot " + slot); + } + } }); + // User runs StakeProgram.authorize (this fails) + it("(user 0) Deposit stake to the LST pool", async () => { - console.log(" stake acc " + validators[0].splPool); - console.log(" wallet " + users[0].wallet.publicKey); - console.log(" stake acc " + users[0].accounts.get("v0_stakeacc")); - // TODO this doesn't work with banks client, rewrite from source (ew) - // const tx = await SinglePoolProgram.deposit({ - // // @ts-ignore // Doesn't matter - // connection: bankRunProvider.connection, - // pool: validators[0].splPool, - // userWallet: users[0].wallet.publicKey, - // userStakeAccount: users[0].accounts.get("v0_stakeacc"), - // // depositFromDefaultAccount: false, - // }); + const userStakeAccount = users[0].accounts.get("v0_stakeacc"); let tx = new Transaction(); const ixes = await depositToSinglePoolIxes( bankRunProvider.connection, users[0].wallet.publicKey, - validators[0].splMint, + validators[0].splPool, + userStakeAccount, verbose ); tx.add(...ixes); diff --git a/tests/utils/spl-staking-utils.ts b/tests/utils/spl-staking-utils.ts index 21752208b..a462f0182 100644 --- a/tests/utils/spl-staking-utils.ts +++ b/tests/utils/spl-staking-utils.ts @@ -1,8 +1,19 @@ +import { + findPoolMintAddress, + findPoolStakeAuthorityAddress, +} from "@solana/spl-single-pool-classic"; import { createAssociatedTokenAccountInstruction, getAssociatedTokenAddressSync, } from "@solana/spl-token"; -import { Connection, PublicKey, TransactionInstruction } from "@solana/web3.js"; +import { + Connection, + PublicKey, + StakeAuthorizationLayout, + StakeProgram, + TransactionInstruction, +} from "@solana/web3.js"; +import { SINGLE_POOL_PROGRAM_ID } from "./types"; export enum SinglePoolAccountType { Uninitialized = 0, @@ -53,9 +64,17 @@ export const decodeSinglePool = (buffer: Buffer) => { export const depositToSinglePoolIxes = async ( connection: Connection, userWallet: PublicKey, - splMint: PublicKey, + splPool: PublicKey, + userStakeAccount: PublicKey, verbose: boolean = false ) => { + const splMint = await findPoolMintAddress(SINGLE_POOL_PROGRAM_ID, splPool); + + const splAuthority = await findPoolStakeAuthorityAddress( + SINGLE_POOL_PROGRAM_ID, + splPool + ); + const ixes: TransactionInstruction[] = []; const lstAta = getAssociatedTokenAddressSync(splMint, userWallet); try { @@ -77,5 +96,23 @@ export const depositToSinglePoolIxes = async ( ); } + const authorizeStakerIxes = StakeProgram.authorize({ + stakePubkey: userStakeAccount, + authorizedPubkey: userWallet, + newAuthorizedPubkey: splAuthority, + stakeAuthorizationType: StakeAuthorizationLayout.Staker, + }).instructions; + + ixes.push(...authorizeStakerIxes); + + const authorizeWithdrawIxes = StakeProgram.authorize({ + stakePubkey: userStakeAccount, + authorizedPubkey: userWallet, + newAuthorizedPubkey: splAuthority, + stakeAuthorizationType: StakeAuthorizationLayout.Withdrawer, + }).instructions; + + ixes.push(...authorizeWithdrawIxes); + return ixes; }; diff --git a/tests/utils/stake-utils.ts b/tests/utils/stake-utils.ts index 207694331..69822b85e 100644 --- a/tests/utils/stake-utils.ts +++ b/tests/utils/stake-utils.ts @@ -504,9 +504,14 @@ export async function getStakeActivation( }; } -export const getEpoch = async (banksClient: BanksClient) => { +export const getEpochAndSlot = async (banksClient: BanksClient) => { let clock = await banksClient.getAccount(SYSVAR_CLOCK_PUBKEY); - // epoch is bytes 16-24 + + // Slot is bytes 0-8 + let slot = new BN(clock.data.slice(0, 8), 10, "le").toNumber(); + + // Epoch is bytes 16-24 let epoch = new BN(clock.data.slice(16, 24), 10, "le").toNumber(); - return epoch; + + return { epoch, slot }; }; From 79669f873e796f5b9130ca87ecf380c090286461 Mon Sep 17 00:00:00 2001 From: Jon Gurary Date: Sat, 14 Sep 2024 01:10:35 -0400 Subject: [PATCH 12/52] Finalized slot warping magic to end the rewards period --- tests/s01_usersStake.spec.ts | 16 ++++++++++------ tests/utils/spl-staking-utils.ts | 4 +++- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/tests/s01_usersStake.spec.ts b/tests/s01_usersStake.spec.ts index b2225bba7..0a09a0b3c 100644 --- a/tests/s01_usersStake.spec.ts +++ b/tests/s01_usersStake.spec.ts @@ -130,6 +130,7 @@ describe("User stakes some native and creates an account", () => { " status: " + stakeStatusAfter.status ); + console.log(""); } // Advance a few slots and send some dummy txes to end the rewards period @@ -137,7 +138,10 @@ describe("User stakes some native and creates an account", () => { // NOTE: ALL STAKE PROGRAM IXES ARE DISABLED DURING THE REWARDS PERIOD. THIS MUST OCCUR OR THE // STAKE PROGRAM CANNOT RUN - for (let i = 0; i < 100; i++) { + if (verbose) { + console.log("Now stalling for a few slots to end the rewards period..."); + } + for (let i = 0; i < 3; i++) { bankrunContext.warpToSlot(BigInt(i + slotAfterWarp + 1)); const dummyTx = new Transaction(); dummyTx.add( @@ -150,11 +154,11 @@ describe("User stakes some native and creates an account", () => { dummyTx.recentBlockhash = bankrunContext.lastBlockhash; dummyTx.sign(users[0].wallet); await banksClient.processTransaction(dummyTx); - if (i % 10 == 0) { - console.log("Dummy ix: " + i); - let { epoch, slot } = await getEpochAndSlot(banksClient); - console.log("is now epoch: " + epoch + " slot " + slot); - } + } + + let { epoch, slot } = await getEpochAndSlot(banksClient); + if (verbose) { + console.log("It is now epoch: " + epoch + " slot " + slot); } }); diff --git a/tests/utils/spl-staking-utils.ts b/tests/utils/spl-staking-utils.ts index a462f0182..92e079bbc 100644 --- a/tests/utils/spl-staking-utils.ts +++ b/tests/utils/spl-staking-utils.ts @@ -84,7 +84,7 @@ export const depositToSinglePoolIxes = async ( } } catch (err) { if (verbose) { - console.log("Did not find token account, creating: " + lstAta); + console.log("Failed to find ata, creating: " + lstAta); } ixes.push( createAssociatedTokenAccountInstruction( @@ -114,5 +114,7 @@ export const depositToSinglePoolIxes = async ( ixes.push(...authorizeWithdrawIxes); + // TODO execute the deposit... + return ixes; }; From 951af448e0fd83413772aebc66399fcb54c22203 Mon Sep 17 00:00:00 2001 From: Jon Gurary Date: Tue, 17 Sep 2024 22:20:54 -0400 Subject: [PATCH 13/52] PoC of deposit of stake to an spl single pool --- tests/rootHooks.ts | 27 +++++---- tests/s01_usersStake.spec.ts | 95 +++++++++++++++++++++++++++++--- tests/utils/genericTests.ts | 3 +- tests/utils/mocks.ts | 2 + tests/utils/spl-staking-utils.ts | 20 ++++++- 5 files changed, 126 insertions(+), 21 deletions(-) diff --git a/tests/rootHooks.ts b/tests/rootHooks.ts index 7ca0403a2..48a6d398b 100644 --- a/tests/rootHooks.ts +++ b/tests/rootHooks.ts @@ -28,6 +28,7 @@ import path from "path"; import { findPoolAddress, findPoolMintAddress, + findPoolStakeAddress, findPoolStakeAuthorityAddress, SinglePoolProgram, } from "@solana/spl-single-pool-classic"; @@ -169,14 +170,13 @@ export const mochaHooks = { } addValidator(validator); - let { poolKey, poolMintKey, poolAuthority } = await createSplStakePool( - provider, - validator - ); + let { poolKey, poolMintKey, poolAuthority, poolStake } = + await createSplStakePool(provider, validator); if (verbose) { - console.log(" spl stake pool: " + poolKey); - console.log(" spl stake mint: " + poolMintKey); - console.log(" spl pool auth: " + poolAuthority); + console.log(" pool..... " + poolKey); + console.log(" mint..... " + poolMintKey); + console.log(" auth..... " + poolAuthority); + console.log(" stake.... " + poolStake); } } @@ -275,15 +275,17 @@ export const createValidator = async ( splPool: PublicKey.default, splMint: PublicKey.default, splAuthority: PublicKey.default, + splStake: PublicKey.default, }; return validator; }; /** - * Create a single-validator spl stake pool. + * Create a single-validator spl stake pool. Copys the pool, mint, authority, and stake accounts to + * the copyKeys slice to be deployed to bankrun * @param provider - * @param validator - mutated, adds the spl keys + * @param validator - mutated, adds the spl keys (pool, mint, authority, stake) */ export const createSplStakePool = async ( provider: AnchorProvider, @@ -306,6 +308,7 @@ export const createSplStakePool = async ( validator.voteAccount ); validator.splPool = poolKey; + copyKeys.push(poolKey); const poolAcc = await provider.connection.getAccountInfo(poolKey); // Rudimentary validation that this account now exists and is owned by the single pool program @@ -322,6 +325,10 @@ export const createSplStakePool = async ( validator.splMint = poolMintKey; copyKeys.push(poolMintKey); + const poolStake = await findPoolStakeAddress(SINGLE_POOL_PROGRAM_ID, poolKey); + validator.splStake = poolStake; + copyKeys.push(poolStake); + const poolAuthority = await findPoolStakeAuthorityAddress( SINGLE_POOL_PROGRAM_ID, poolKey @@ -330,5 +337,5 @@ export const createSplStakePool = async ( // Note: accounts that do not exist (blank PDAs) cannot be pushed) // copyKeys.push(poolAuthority); - return { poolKey, poolMintKey, poolAuthority }; + return { poolKey, poolMintKey, poolAuthority, poolStake }; }; diff --git a/tests/s01_usersStake.spec.ts b/tests/s01_usersStake.spec.ts index 0a09a0b3c..bda75945f 100644 --- a/tests/s01_usersStake.spec.ts +++ b/tests/s01_usersStake.spec.ts @@ -21,11 +21,22 @@ import { getStakeAccount, getStakeActivation, } from "./utils/stake-utils"; -import { assertBNEqual, assertKeysEqual } from "./utils/genericTests"; +import { + assertBNEqual, + assertKeysEqual, + getTokenBalance, +} from "./utils/genericTests"; import { u64MAX_BN } from "./utils/types"; -import { SinglePoolProgram } from "@solana/spl-single-pool-classic"; +import { + SinglePoolInstruction, + SinglePoolProgram, +} from "@solana/spl-single-pool-classic"; import { getAssociatedTokenAddressSync } from "@mrgnlabs/mrgn-common"; -import { depositToSinglePoolIxes } from "./utils/spl-staking-utils"; +import { + decodeSinglePool, + depositToSinglePoolIxes, +} from "./utils/spl-staking-utils"; +import { assert } from "chai"; describe("User stakes some native and creates an account", () => { /** Users's validator 0 stake account */ @@ -52,7 +63,7 @@ describe("User stakes some native and creates an account", () => { " in native)" ); } - users[0].accounts.set("v0_stakeacc", stakeAccountKeypair.publicKey); + users[0].accounts.set("v0_stakeAcc", stakeAccount); let delegateTx = delegateStake( users[0], @@ -162,11 +173,43 @@ describe("User stakes some native and creates an account", () => { } }); - // User runs StakeProgram.authorize (this fails) + it("(user 0) Deposits stake to the LST pool", async () => { + const userStakeAccount = users[0].accounts.get("v0_stakeAcc"); + // Note: you can use `findPoolMintAddress(SINGLE_POOL_PROGRAM_ID, splPool);` if mint is not known. + const lstAta = getAssociatedTokenAddressSync( + validators[0].splMint, + users[0].wallet.publicKey + ); + users[0].accounts.set("v0_lstAta", lstAta); - it("(user 0) Deposit stake to the LST pool", async () => { - const userStakeAccount = users[0].accounts.get("v0_stakeacc"); + // Note: user stake account exists before, but is closed after + // Here we note the balance of the stake account prior + const stakeAccountInfo = await bankRunProvider.connection.getAccountInfo( + userStakeAccount + ); + const stakeAccBefore = getStakeAccount(stakeAccountInfo.data); + const rent = new BN(stakeAccBefore.meta.rentExemptReserve.toString()); + const delegationBefore = Number( + stakeAccBefore.stake.delegation.stake.toString() + ); + assertBNEqual( + new BN(delegationBefore), + new BN(10 * LAMPORTS_PER_SOL).sub(rent) + ); + + // The spl stake pool account is already infused with 1 SOL at init + const splStakeInfoBefore = await bankRunProvider.connection.getAccountInfo( + validators[0].splStake + ); + const splStakePoolBefore = getStakeAccount(splStakeInfoBefore.data); + const delegationSplPoolBefore = new BN( + splStakePoolBefore.stake.delegation.stake.toString() + ); + if (verbose) { + console.log("pool stake before: " + delegationSplPoolBefore.toString()); + } + // Create lst ata, transfer authority, execute the deposit let tx = new Transaction(); const ixes = await depositToSinglePoolIxes( bankRunProvider.connection, @@ -176,10 +219,44 @@ describe("User stakes some native and creates an account", () => { verbose ); tx.add(...ixes); - tx.recentBlockhash = bankrunContext.lastBlockhash; tx.sign(users[0].wallet); - // @ts-ignore // Doesn't matter await banksClient.processTransaction(tx); + + // The stake account no longer exists + try { + const accountInfo = await bankRunProvider.connection.getAccountInfo( + userStakeAccount + ); + assert.ok( + accountInfo === null, + "The account should not exist, but it does." + ); + } catch (err) { + assert.ok(true, "The account does not exist."); + } + + const [lstAfter, splStakePoolInfo] = await Promise.all([ + getTokenBalance(bankRunProvider, lstAta), + bankRunProvider.connection.getAccountInfo(validators[0].splStake), + ]); + if (verbose) { + console.log("lst after: " + lstAfter.toLocaleString()); + } + // LST tokens are issued 1:1 with stake because there has been zero appreciation + assert.equal(lstAfter, delegationBefore); + + const splStakePool = getStakeAccount(splStakePoolInfo.data); + const delegationSplPoolAfter = new BN( + splStakePool.stake.delegation.stake.toString() + ); + if (verbose) { + console.log("pool stake after: " + delegationSplPoolAfter.toString()); + } + // The stake pool gained all of the stake that was held in the user stake acc + assertBNEqual( + delegationSplPoolAfter.sub(delegationSplPoolBefore), + delegationBefore + ); }); }); diff --git a/tests/utils/genericTests.ts b/tests/utils/genericTests.ts index 3923219b1..8401408a1 100644 --- a/tests/utils/genericTests.ts +++ b/tests/utils/genericTests.ts @@ -3,6 +3,7 @@ import { WrappedI80F48, wrappedI80F48toBigNumber } from "@mrgnlabs/mrgn-common"; import type { RawAccount } from "@solana/spl-token"; import { AccountLayout } from "@solana/spl-token"; import { PublicKey } from "@solana/web3.js"; +import { BankrunProvider } from "anchor-bankrun"; import BigNumber from "bignumber.js"; import BN from "bn.js"; import { assert } from "chai"; @@ -131,7 +132,7 @@ export const assertBNApproximately = ( * @returns */ export const getTokenBalance = async ( - provider: AnchorProvider, + provider: AnchorProvider | BankrunProvider , account: PublicKey ) => { const accountInfo = await provider.connection.getAccountInfo(account); diff --git a/tests/utils/mocks.ts b/tests/utils/mocks.ts index a9ef271cc..5c477bf2a 100644 --- a/tests/utils/mocks.ts +++ b/tests/utils/mocks.ts @@ -359,4 +359,6 @@ export type Validator = { splMint: PublicKey; /** spl pool's authority for LST management (a PDA automatically created on init) */ splAuthority: PublicKey; + /** spl pool's stake account */ + splStake: PublicKey; }; diff --git a/tests/utils/spl-staking-utils.ts b/tests/utils/spl-staking-utils.ts index 92e079bbc..b27dd7a72 100644 --- a/tests/utils/spl-staking-utils.ts +++ b/tests/utils/spl-staking-utils.ts @@ -1,6 +1,7 @@ import { findPoolMintAddress, findPoolStakeAuthorityAddress, + SinglePoolInstruction, } from "@solana/spl-single-pool-classic"; import { createAssociatedTokenAccountInstruction, @@ -61,6 +62,16 @@ export const decodeSinglePool = (buffer: Buffer) => { // See `https://www.npmjs.com/package/@solana/spl-single-pool` transactions.ts for the original +/** + * Builds ixes to create the LST ata as-needed, pass stake authority to the spl pool, and deposit to + * the stake pool + * @param connection + * @param userWallet + * @param splPool + * @param userStakeAccount + * @param verbose + * @returns + */ export const depositToSinglePoolIxes = async ( connection: Connection, userWallet: PublicKey, @@ -114,7 +125,14 @@ export const depositToSinglePoolIxes = async ( ixes.push(...authorizeWithdrawIxes); - // TODO execute the deposit... + const depositIx = await SinglePoolInstruction.depositStake( + splPool, + userStakeAccount, + lstAta, + userWallet + ); + + ixes.push(depositIx); return ixes; }; From 04856e3cc4771f7046c1effe4c249027407914d5 Mon Sep 17 00:00:00 2001 From: Jon Gurary Date: Wed, 18 Sep 2024 13:12:13 -0400 Subject: [PATCH 14/52] Merge with additional anchor tests in progress --- Anchor.toml | 7 ++++--- tests/s01_usersStake.spec.ts | 12 +++++++----- tests/utils/spl-staking-utils.ts | 13 +++++++++++++ 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/Anchor.toml b/Anchor.toml index 43768cd3a..90ff2b5dc 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -23,11 +23,12 @@ url = "https://api.apr.dev" cluster = "Localnet" wallet = "~/.config/solana/id.json" +# (remove RUST_LOG= to see bankRun logs) [scripts] -# test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/*.spec.ts --exit --require tests/rootHooks.ts" +test = "RUST_LOG= yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/*.spec.ts --exit --require tests/rootHooks.ts" -# Staking Collatizer only (remove RUST_LOG= to see bankRun logs) -test = "RUST_LOG= yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/s*.spec.ts --exit --require tests/rootHooks.ts" +# Staking Collatizer only +# test = "RUST_LOG= yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/s*.spec.ts --exit --require tests/rootHooks.ts" [test] startup_wait = 5000 diff --git a/tests/s01_usersStake.spec.ts b/tests/s01_usersStake.spec.ts index bda75945f..1542386d1 100644 --- a/tests/s01_usersStake.spec.ts +++ b/tests/s01_usersStake.spec.ts @@ -35,6 +35,7 @@ import { getAssociatedTokenAddressSync } from "@mrgnlabs/mrgn-common"; import { decodeSinglePool, depositToSinglePoolIxes, + getBankrunBlockhash, } from "./utils/spl-staking-utils"; import { assert } from "chai"; @@ -48,7 +49,8 @@ describe("User stakes some native and creates an account", () => { users[0], stake * LAMPORTS_PER_SOL ); - createTx.recentBlockhash = bankrunContext.lastBlockhash; + // Note: bankrunContext.lastBlockhash only works if non-bankrun tests didn't run previously + createTx.recentBlockhash = await getBankrunBlockhash(bankrunContext); createTx.sign(users[0].wallet, stakeAccountKeypair); await banksClient.processTransaction(createTx); stakeAccount = stakeAccountKeypair.publicKey; @@ -70,7 +72,7 @@ describe("User stakes some native and creates an account", () => { stakeAccount, validators[0].voteAccount ); - delegateTx.recentBlockhash = bankrunContext.lastBlockhash; + delegateTx.recentBlockhash = await getBankrunBlockhash(bankrunContext); delegateTx.sign(users[0].wallet); await banksClient.processTransaction(delegateTx); @@ -162,7 +164,7 @@ describe("User stakes some native and creates an account", () => { lamports: i, }) ); - dummyTx.recentBlockhash = bankrunContext.lastBlockhash; + dummyTx.recentBlockhash = await getBankrunBlockhash(bankrunContext); dummyTx.sign(users[0].wallet); await banksClient.processTransaction(dummyTx); } @@ -175,7 +177,7 @@ describe("User stakes some native and creates an account", () => { it("(user 0) Deposits stake to the LST pool", async () => { const userStakeAccount = users[0].accounts.get("v0_stakeAcc"); - // Note: you can use `findPoolMintAddress(SINGLE_POOL_PROGRAM_ID, splPool);` if mint is not known. + // Note: use `findPoolMintAddress(SINGLE_POOL_PROGRAM_ID, splPool);` if mint is not known. const lstAta = getAssociatedTokenAddressSync( validators[0].splMint, users[0].wallet.publicKey @@ -219,7 +221,7 @@ describe("User stakes some native and creates an account", () => { verbose ); tx.add(...ixes); - tx.recentBlockhash = bankrunContext.lastBlockhash; + tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); tx.sign(users[0].wallet); await banksClient.processTransaction(tx); diff --git a/tests/utils/spl-staking-utils.ts b/tests/utils/spl-staking-utils.ts index b27dd7a72..3893af339 100644 --- a/tests/utils/spl-staking-utils.ts +++ b/tests/utils/spl-staking-utils.ts @@ -15,6 +15,7 @@ import { TransactionInstruction, } from "@solana/web3.js"; import { SINGLE_POOL_PROGRAM_ID } from "./types"; +import { ProgramTestContext } from "solana-bankrun"; export enum SinglePoolAccountType { Uninitialized = 0, @@ -136,3 +137,15 @@ export const depositToSinglePoolIxes = async ( return ixes; }; + +/** + * Generally, use this instead of `bankrunContext.lastBlockhash` (which does not work if the test + * has already run for some time and the blockhash has advanced) + * @param bankrunContext + * @returns + */ +export const getBankrunBlockhash = async ( + bankrunContext: ProgramTestContext +) => { + return (await bankrunContext.banksClient.getLatestBlockhash())[0]; +}; From 4b08d981aef8e23b168c5acdf133acc35df7b387 Mon Sep 17 00:00:00 2001 From: Jon Gurary Date: Wed, 18 Sep 2024 14:21:02 -0400 Subject: [PATCH 15/52] user init and deposit happy paths --- .../marginfi/src/state/marginfi_account.rs | 3 + tests/01_initGroup.spec.ts | 2 +- tests/02_configGroup.spec.ts | 2 +- tests/03_addBank.spec.ts | 15 +- tests/04_configureBank.spec.ts | 2 +- tests/05_setupEmissions.spec.ts | 2 +- tests/06_initUser.spec.ts | 69 ++++++++ tests/07_deposit.spec.ts | 150 ++++++++++++++++++ tests/rootHooks.ts | 2 +- tests/utils/genericTests.ts | 7 +- ...{instructions.ts => group-instructions.ts} | 2 +- tests/utils/mocks.ts | 7 +- tests/utils/stake-utils.ts | 6 +- tests/utils/types.ts | 10 +- tests/utils/user-instructions.ts | 70 ++++++++ 15 files changed, 324 insertions(+), 25 deletions(-) create mode 100644 tests/06_initUser.spec.ts create mode 100644 tests/07_deposit.spec.ts rename tests/utils/{instructions.ts => group-instructions.ts} (98%) create mode 100644 tests/utils/user-instructions.ts diff --git a/programs/marginfi/src/state/marginfi_account.rs b/programs/marginfi/src/state/marginfi_account.rs index ea3944d71..f18fe143e 100644 --- a/programs/marginfi/src/state/marginfi_account.rs +++ b/programs/marginfi/src/state/marginfi_account.rs @@ -41,6 +41,9 @@ pub struct MarginfiAccount { /// Flags: /// - DISABLED_FLAG = 1 << 0 = 1 - This flag indicates that the account is disabled, /// and no further actions can be taken on it. + /// - IN_FLASHLOAN_FLAG (1 << 1) + /// - FLASHLOAN_ENABLED_FLAG (1 << 2) + /// - TRANSFER_AUTHORITY_ALLOWED_FLAG (1 << 3) pub account_flags: u64, // 8 pub _padding: [u64; 63], // 504 } diff --git a/tests/01_initGroup.spec.ts b/tests/01_initGroup.spec.ts index aba7c29ad..f8e903772 100644 --- a/tests/01_initGroup.spec.ts +++ b/tests/01_initGroup.spec.ts @@ -3,7 +3,7 @@ import { workspace, } from "@coral-xyz/anchor"; import { Transaction } from "@solana/web3.js"; -import { groupInitialize } from "./utils/instructions"; +import { groupInitialize } from "./utils/group-instructions"; import { Marginfi } from "../target/types/marginfi"; import { groupAdmin, marginfiGroup, verbose } from "./rootHooks"; import { assertKeysEqual } from "./utils/genericTests"; diff --git a/tests/02_configGroup.spec.ts b/tests/02_configGroup.spec.ts index 6c90eb400..6781455d1 100644 --- a/tests/02_configGroup.spec.ts +++ b/tests/02_configGroup.spec.ts @@ -6,7 +6,7 @@ import { workspace, } from "@coral-xyz/anchor"; import { Keypair, Transaction } from "@solana/web3.js"; -import { groupConfigure } from "./utils/instructions"; +import { groupConfigure } from "./utils/group-instructions"; import { Marginfi } from "../target/types/marginfi"; import { groupAdmin, marginfiGroup } from "./rootHooks"; import { assertKeysEqual } from "./utils/genericTests"; diff --git a/tests/03_addBank.spec.ts b/tests/03_addBank.spec.ts index a654e117d..452a8fdf0 100644 --- a/tests/03_addBank.spec.ts +++ b/tests/03_addBank.spec.ts @@ -1,6 +1,6 @@ import { BN, Program, workspace } from "@coral-xyz/anchor"; import { PublicKey, Transaction } from "@solana/web3.js"; -import { addBank } from "./utils/instructions"; +import { addBank } from "./utils/group-instructions"; import { Marginfi } from "../target/types/marginfi"; import { bankKeypairA, @@ -114,7 +114,7 @@ describe("Lending pool add bank (add bank to group)", () => { assertI80F48Equal(config.assetWeightMaint, 1); assertI80F48Equal(config.liabilityWeightInit, 1); assertI80F48Equal(config.liabilityWeightMaint, 1); - assertBNEqual(config.depositLimit, 1_000_000_000); + assertBNEqual(config.depositLimit, 100_000_000_000); const tolerance = 0.000001; assertI80F48Approx(interest.optimalUtilizationRate, 0.5, tolerance); @@ -127,9 +127,9 @@ describe("Lending pool add bank (add bank to group)", () => { assert.deepEqual(config.operationalState, { operational: {} }); assert.deepEqual(config.oracleSetup, { pythLegacy: {} }); - assertBNEqual(config.borrowLimit, 1_000_000_000); + assertBNEqual(config.borrowLimit, 100_000_000_000); assert.deepEqual(config.riskTier, { collateral: {} }); - assertBNEqual(config.totalAssetValueInitLimit, 100_000_000_000); + assertBNEqual(config.totalAssetValueInitLimit, 1_000_000_000_000); assert.equal(config.oracleMaxAge, 100); }); @@ -298,13 +298,16 @@ describe("Lending pool add bank (add bank to group)", () => { assertBNEqual(cloudConfig.totalAssetValueInitLimit, 0); assert.equal(cloudConfig.oracleMaxAge, 60); + // Assert emissions mint (one of the last fields) is also aligned correctly. let pyUsdcBankKey = new PublicKey( "Fe5QkKPVAh629UPP5aJ8sDZu8HTfe6M26jDQkKyXVhoA" ); let pyUsdcBankData = ( await program.provider.connection.getAccountInfo(pyUsdcBankKey) ).data.subarray(8); - printBufferGroups(pyUsdcBankData, 16, 896); + if (printBuffers) { + printBufferGroups(pyUsdcBankData, 16, 896); + } const pb = await program.account.bank.fetch(pyUsdcBankKey); assertKeysEqual( @@ -314,4 +317,4 @@ describe("Lending pool add bank (add bank to group)", () => { }); }); -// TODO add bank with seed \ No newline at end of file +// TODO add bank with seed diff --git a/tests/04_configureBank.spec.ts b/tests/04_configureBank.spec.ts index 272955ef2..23f4d0590 100644 --- a/tests/04_configureBank.spec.ts +++ b/tests/04_configureBank.spec.ts @@ -1,6 +1,6 @@ import { BN, Program, workspace } from "@coral-xyz/anchor"; import { Transaction } from "@solana/web3.js"; -import { configureBank } from "./utils/instructions"; +import { configureBank } from "./utils/group-instructions"; import { Marginfi } from "../target/types/marginfi"; import { bankKeypairUsdc, groupAdmin, marginfiGroup } from "./rootHooks"; import { assertBNEqual, assertI80F48Approx } from "./utils/genericTests"; diff --git a/tests/05_setupEmissions.spec.ts b/tests/05_setupEmissions.spec.ts index 0da9f1dea..4162af455 100644 --- a/tests/05_setupEmissions.spec.ts +++ b/tests/05_setupEmissions.spec.ts @@ -7,7 +7,7 @@ import { workspace, } from "@coral-xyz/anchor"; import { Transaction } from "@solana/web3.js"; -import { setupEmissions, updateEmissions } from "./utils/instructions"; +import { setupEmissions, updateEmissions } from "./utils/group-instructions"; import { Marginfi } from "../target/types/marginfi"; import { bankKeypairUsdc, diff --git a/tests/06_initUser.spec.ts b/tests/06_initUser.spec.ts new file mode 100644 index 000000000..b20c75afc --- /dev/null +++ b/tests/06_initUser.spec.ts @@ -0,0 +1,69 @@ +import { Program, workspace } from "@coral-xyz/anchor"; +import { Keypair, Transaction } from "@solana/web3.js"; +import { Marginfi } from "../target/types/marginfi"; +import { marginfiGroup, users } from "./rootHooks"; +import { + assertBNEqual, + assertI80F48Equal, + assertKeyDefault, + assertKeysEqual, +} from "./utils/genericTests"; +import { assert } from "chai"; +import { accountInit } from "./utils/user-instructions"; +import { USER_ACCOUNT } from "./utils/mocks"; + +describe("Initialize user account", () => { + const program = workspace.Marginfi as Program; + + it("(user 0) Initialize user account - happy path", async () => { + const accountKeypair = Keypair.generate(); + const accountKey = accountKeypair.publicKey; + users[0].accounts.set(USER_ACCOUNT, accountKey); + + let tx: Transaction = new Transaction(); + tx.add( + await accountInit(program, { + marginfiGroup: marginfiGroup.publicKey, + marginfiAccount: accountKey, + authority: users[0].wallet.publicKey, + feePayer: users[0].wallet.publicKey, + }) + ); + await users[0].userMarginProgram.provider.sendAndConfirm(tx, [ + accountKeypair, + ]); + + const userAcc = await program.account.marginfiAccount.fetch(accountKey); + assertKeysEqual(userAcc.group, marginfiGroup.publicKey); + assertKeysEqual(userAcc.authority, users[0].wallet.publicKey); + const balances = userAcc.lendingAccount.balances; + for (let i = 0; i < balances.length; i++) { + assert.equal(balances[i].active, false); + assertKeyDefault(balances[i].bankPk); + assertI80F48Equal(balances[i].assetShares, 0); + assertI80F48Equal(balances[i].liabilityShares, 0); + assertI80F48Equal(balances[i].emissionsOutstanding, 0); + assertBNEqual(balances[i].lastUpdate, 0); + } + assertBNEqual(userAcc.accountFlags, 0); + }); + + it("(user 1) Initialize user account - happy path", async () => { + const accountKeypair = Keypair.generate(); + const accountKey = accountKeypair.publicKey; + users[1].accounts.set(USER_ACCOUNT, accountKey); + + let tx: Transaction = new Transaction(); + tx.add( + await accountInit(program, { + marginfiGroup: marginfiGroup.publicKey, + marginfiAccount: accountKey, + authority: users[1].wallet.publicKey, + feePayer: users[1].wallet.publicKey, + }) + ); + await users[1].userMarginProgram.provider.sendAndConfirm(tx, [ + accountKeypair, + ]); + }); +}); diff --git a/tests/07_deposit.spec.ts b/tests/07_deposit.spec.ts new file mode 100644 index 000000000..3837d61c6 --- /dev/null +++ b/tests/07_deposit.spec.ts @@ -0,0 +1,150 @@ +import { + AnchorProvider, + BN, + getProvider, + Program, + Wallet, + workspace, +} from "@coral-xyz/anchor"; +import { Transaction } from "@solana/web3.js"; +import { Marginfi } from "../target/types/marginfi"; +import { + bankKeypairA, + bankKeypairUsdc, + ecosystem, + marginfiGroup, + users, + verbose, +} from "./rootHooks"; +import { + assertBNApproximately, + assertI80F48Approx, + assertI80F48Equal, + getTokenBalance, +} from "./utils/genericTests"; +import { assert } from "chai"; +import { depositIx } from "./utils/user-instructions"; +import { USER_ACCOUNT } from "./utils/mocks"; +import { createMintToInstruction } from "@solana/spl-token"; + +describe("Deposit funds", () => { + const program = workspace.Marginfi as Program; + const provider = getProvider() as AnchorProvider; + const wallet = provider.wallet as Wallet; + const depositAmountA = 2; + const depositAmountA_native = new BN( + depositAmountA * 10 ** ecosystem.tokenADecimals + ); + + const depositAmountUsdc = 100; + const depositAmountUsdc_native = new BN( + depositAmountUsdc * 10 ** ecosystem.usdcDecimals + ); + + it("(Fund user 0 and user 1 USDC/Token A token accounts", async () => { + let tx = new Transaction(); + for (let i = 0; i < users.length; i++) { + tx.add( + createMintToInstruction( + ecosystem.tokenAMint.publicKey, + users[i].tokenAAccount, + wallet.publicKey, + 100 * 10 ** ecosystem.tokenADecimals + ) + ); + tx.add( + createMintToInstruction( + ecosystem.usdcMint.publicKey, + users[i].usdcAccount, + wallet.publicKey, + 10000 * 10 ** ecosystem.usdcDecimals + ) + ); + } + await program.provider.sendAndConfirm(tx); + }); + + it("(user 0) deposit token A to bank - happy path", async () => { + const userABefore = await getTokenBalance(provider, users[0].tokenAAccount); + if (verbose) { + console.log("user 0 A before: " + userABefore.toLocaleString()); + } + + const user0Account = users[0].accounts.get(USER_ACCOUNT); + + await users[0].userMarginProgram.provider.sendAndConfirm( + new Transaction().add( + await depositIx(program, { + marginfiGroup: marginfiGroup.publicKey, + marginfiAccount: user0Account, + authority: users[0].wallet.publicKey, + bank: bankKeypairA.publicKey, + tokenAccount: users[0].tokenAAccount, + amount: depositAmountA_native, + }) + ) + ); + + const userAcc = await program.account.marginfiAccount.fetch(user0Account); + const balances = userAcc.lendingAccount.balances; + assert.equal(balances[0].active, true); + // Note: The first deposit issues shares 1:1 and the shares use the same decimals + assertI80F48Approx(balances[0].assetShares, depositAmountA_native); + assertI80F48Equal(balances[0].liabilityShares, 0); + assertI80F48Equal(balances[0].emissionsOutstanding, 0); + + let now = Math.floor(Date.now() / 1000); + assertBNApproximately(balances[0].lastUpdate, now, 2); + + const userAAfter = await getTokenBalance(provider, users[0].tokenAAccount); + if (verbose) { + console.log("user 0 A after: " + userABefore.toLocaleString()); + } + assert.equal(userABefore - depositAmountA_native.toNumber(), userAAfter); + }); + + it("(user 1) deposit USDC to bank - happy path", async () => { + const userUsdcBefore = await getTokenBalance( + provider, + users[1].usdcAccount + ); + if (verbose) { + console.log("user 1 usdc before: " + userUsdcBefore.toLocaleString()); + } + + const user1Account = users[1].accounts.get(USER_ACCOUNT); + + await users[1].userMarginProgram.provider.sendAndConfirm( + new Transaction().add( + await depositIx(program, { + marginfiGroup: marginfiGroup.publicKey, + marginfiAccount: user1Account, + authority: users[1].wallet.publicKey, + bank: bankKeypairUsdc.publicKey, + tokenAccount: users[1].usdcAccount, + amount: depositAmountUsdc_native, + }) + ) + ); + + const userAcc = await program.account.marginfiAccount.fetch(user1Account); + const balances = userAcc.lendingAccount.balances; + assert.equal(balances[0].active, true); + // Note: The first deposit issues shares 1:1 and the shares use the same decimals + assertI80F48Approx(balances[0].assetShares, depositAmountUsdc_native); + assertI80F48Equal(balances[0].liabilityShares, 0); + assertI80F48Equal(balances[0].emissionsOutstanding, 0); + + let now = Math.floor(Date.now() / 1000); + assertBNApproximately(balances[0].lastUpdate, now, 2); + + const userUsdcAfter = await getTokenBalance(provider, users[1].usdcAccount); + if (verbose) { + console.log("user 1 usdc after: " + userUsdcBefore.toLocaleString()); + } + assert.equal( + userUsdcBefore - depositAmountUsdc_native.toNumber(), + userUsdcAfter + ); + }); +}); diff --git a/tests/rootHooks.ts b/tests/rootHooks.ts index 9666fd0ca..0d91fddf2 100644 --- a/tests/rootHooks.ts +++ b/tests/rootHooks.ts @@ -4,7 +4,7 @@ import { echoEcosystemInfo, Ecosystem, getGenericEcosystem, - mockUser as MockUser, + MockUser as MockUser, Oracles, setupTestUser, SetupTestUserOptions, diff --git a/tests/utils/genericTests.ts b/tests/utils/genericTests.ts index 32b89ea15..730167158 100644 --- a/tests/utils/genericTests.ts +++ b/tests/utils/genericTests.ts @@ -73,7 +73,7 @@ export const assertI80F48Equal = ( export const assertI80F48Approx = ( a: WrappedI80F48, b: WrappedI80F48 | BN | number, - tolerance: number = .000001 + tolerance: number = 0.000001 ) => { const bigA = wrappedI80F48toBigNumber(a); let bigB: BigNumber; @@ -93,7 +93,8 @@ export const assertI80F48Approx = ( if (diff.isGreaterThan(allowedDifference)) { throw new Error( - `Values are not approximately equal. Difference: ${diff.toString()}, Allowed Tolerance: ${tolerance}` + `Values are not approximately equal. A: ${bigA.toString()} B: ${bigB.toString()} + Difference: ${diff.toString()}, Allowed Tolerance: ${tolerance}` ); } }; @@ -132,7 +133,7 @@ export const assertBNApproximately = ( * @returns */ export const getTokenBalance = async ( - provider: AnchorProvider | BankrunProvider , + provider: AnchorProvider | BankrunProvider, account: PublicKey ) => { const accountInfo = await provider.connection.getAccountInfo(account); diff --git a/tests/utils/instructions.ts b/tests/utils/group-instructions.ts similarity index 98% rename from tests/utils/instructions.ts rename to tests/utils/group-instructions.ts index 5cba951ee..298342220 100644 --- a/tests/utils/instructions.ts +++ b/tests/utils/group-instructions.ts @@ -32,7 +32,7 @@ export const addBank = (program: Program, args: AddBankArgs) => { // const id = program.programId; // const bank = args.bank; - // Note that oracle is passed as a key in config and as an acc in remaining accs... + // Note: oracle is passed as a key in config AND as an acc in remaining accs const oracleMeta: AccountMeta = { pubkey: args.config.oracleKey, isSigner: false, diff --git a/tests/utils/mocks.ts b/tests/utils/mocks.ts index 5c477bf2a..d74643806 100644 --- a/tests/utils/mocks.ts +++ b/tests/utils/mocks.ts @@ -85,7 +85,7 @@ export const echoEcosystemInfo = ( /** * A typical user, with a wallet, ATAs for mock tokens, and a program to sign/send txes with. */ -export type mockUser = { +export type MockUser = { wallet: Keypair; /** Users's ATA for wsol*/ wsolAccount: PublicKey; @@ -101,6 +101,9 @@ export type mockUser = { accounts: Map; }; +/** in mockUser.accounts, key used to get/set the users's account for group 0 */ +export const USER_ACCOUNT: string = "g0_acc"; + /** * Options to skip various parts of mock user setup */ @@ -202,7 +205,7 @@ export const setupTestUser = async ( await provider.sendAndConfirm(tx, [wallet]); - const user: mockUser = { + const user: MockUser = { wallet: userWalletKeypair, wsolAccount: wsolAccount, tokenAAccount: tokenAAccount, diff --git a/tests/utils/stake-utils.ts b/tests/utils/stake-utils.ts index 69822b85e..a970c46ac 100644 --- a/tests/utils/stake-utils.ts +++ b/tests/utils/stake-utils.ts @@ -7,7 +7,7 @@ import { Connection, SYSVAR_CLOCK_PUBKEY, } from "@solana/web3.js"; -import { mockUser } from "./mocks"; +import { MockUser } from "./mocks"; import { BanksClient } from "solana-bankrun"; import { BN } from "@coral-xyz/anchor"; @@ -17,7 +17,7 @@ import { BN } from "@coral-xyz/anchor"; * @param amount - in SOL (lamports), in native decimals * @returns */ -export const createStakeAccount = (user: mockUser, amount: number) => { +export const createStakeAccount = (user: MockUser, amount: number) => { const stakeAccount = Keypair.generate(); const userPublicKey = user.wallet.publicKey; @@ -49,7 +49,7 @@ export const createStakeAccount = (user: mockUser, amount: number) => { * @param validatorVoteAccount */ export const delegateStake = ( - user: mockUser, + user: MockUser, stakeAccount: PublicKey, validatorVoteAccount: PublicKey ) => { diff --git a/tests/utils/types.ts b/tests/utils/types.ts index c4e208085..a9e72b78e 100644 --- a/tests/utils/types.ts +++ b/tests/utils/types.ts @@ -60,8 +60,8 @@ export type BankConfig = { * * all weights are 1 * * state = operational, risk tier = collateral * * uses the given oracle, assumes it's = pythLegacy - * * 1_000_000_000 deposit/borrow limit - * * 100_000_000_000 total asset value limit + * * 100_000_000_000 deposit/borrow limit + * * 1_000_000_000_000 total asset value limit * @returns */ export const defaultBankConfig = (oracleKey: PublicKey) => { @@ -70,7 +70,7 @@ export const defaultBankConfig = (oracleKey: PublicKey) => { assetWeightMaint: I80F48_ONE, liabilityWeightInit: I80F48_ONE, liabilityWeightMain: I80F48_ONE, - depositLimit: new BN(1_000_000_000), + depositLimit: new BN(100_000_000_000), interestRateConfig: defaultInterestRateConfigRaw(), operationalState: { operational: undefined, @@ -79,11 +79,11 @@ export const defaultBankConfig = (oracleKey: PublicKey) => { pythLegacy: undefined, }, oracleKey: oracleKey, - borrowLimit: new BN(1_000_000_000), + borrowLimit: new BN(100_000_000_000), riskTier: { collateral: undefined, }, - totalAssetValueInitLimit: new BN(100_000_000_000), + totalAssetValueInitLimit: new BN(1_000_000_000_000), oracleMaxAge: 100, }; return config; diff --git a/tests/utils/user-instructions.ts b/tests/utils/user-instructions.ts new file mode 100644 index 000000000..743a67243 --- /dev/null +++ b/tests/utils/user-instructions.ts @@ -0,0 +1,70 @@ +import { BN, Program } from "@coral-xyz/anchor"; +import { PublicKey } from "@solana/web3.js"; +import { Marginfi } from "../../target/types/marginfi"; +import { TOKEN_PROGRAM_ID } from "@solana/spl-token"; + +export type AccountInitArgs = { + marginfiGroup: PublicKey; + marginfiAccount: PublicKey; + authority: PublicKey; + feePayer: PublicKey; +}; + +/** + * Init a user account for some group. + * * fee payer and authority must both sign. + * * account must be a fresh keypair and must also sign + * @param program + * @param args + * @returns + */ +export const accountInit = ( + program: Program, + args: AccountInitArgs +) => { + const ix = program.methods + .marginfiAccountInitialize() + .accounts({ + marginfiGroup: args.marginfiGroup, + marginfiAccount: args.marginfiAccount, + authority: args.authority, + feePayer: args.feePayer, + // systemProgram + }) + .instruction(); + + return ix; +}; + +export type DepositArgs = { + marginfiGroup: PublicKey; + marginfiAccount: PublicKey; + authority: PublicKey; + bank: PublicKey; + tokenAccount: PublicKey; + amount: BN; +}; + +/** + * Deposit to a bank + * * `authority` must sign and own the `tokenAccount` + * @param program + * @param args + * @returns + */ +export const depositIx = (program: Program, args: DepositArgs) => { + const ix = program.methods + .lendingAccountDeposit(args.amount) + .accounts({ + marginfiGroup: args.marginfiGroup, + marginfiAccount: args.marginfiAccount, + signer: args.authority, + bank: args.bank, + signerTokenAccount: args.tokenAccount, + // liquidityVault = deriveLiquidityVault(id, bank) + tokenProgram: TOKEN_PROGRAM_ID, + }) + .instruction(); + + return ix; +}; From e406b967441d455eafb592407dce56ae9a821cc8 Mon Sep 17 00:00:00 2001 From: Jon Gurary Date: Thu, 19 Sep 2024 16:06:09 -0400 Subject: [PATCH 16/52] Tests for deposit/borrow complete, now bypasses oracle age checks in localnet --- programs/marginfi/src/state/price.rs | 29 ++++-- tests/07_deposit.spec.ts | 46 ++++++---- tests/08_borrow.spec.ts | 130 +++++++++++++++++++++++++++ tests/rootHooks.ts | 10 +-- tests/utils/pyth_mocks.ts | 11 ++- tests/utils/user-instructions.ts | 47 +++++++++- 6 files changed, 242 insertions(+), 31 deletions(-) create mode 100644 tests/08_borrow.spec.ts diff --git a/programs/marginfi/src/state/price.rs b/programs/marginfi/src/state/price.rs index 2fe51ceb3..83ccb1c16 100644 --- a/programs/marginfi/src/state/price.rs +++ b/programs/marginfi/src/state/price.rs @@ -214,13 +214,30 @@ impl PythLegacyPriceFeed { pub fn load_checked(ai: &AccountInfo, current_time: i64, max_age: u64) -> MarginfiResult { let price_feed = load_pyth_price_feed(ai)?; - let ema_price = price_feed - .get_ema_price_no_older_than(current_time, max_age) - .ok_or(MarginfiError::StaleOracle)?; + // Note: mainnet/staging/devnet use oracle age, localnet ignores oracle age + let ema_price = if cfg!(any( + feature = "mainnet-beta", + feature = "staging", + feature = "devnet" + )) { + price_feed + .get_ema_price_no_older_than(current_time, max_age) + .ok_or(MarginfiError::StaleOracle)? + } else { + price_feed.get_ema_price_unchecked() + }; - let price = price_feed - .get_price_no_older_than(current_time, max_age) - .ok_or(MarginfiError::StaleOracle)?; + let price = if cfg!(any( + feature = "mainnet-beta", + feature = "staging", + feature = "devnet" + )) { + price_feed + .get_price_no_older_than(current_time, max_age) + .ok_or(MarginfiError::StaleOracle)? + } else { + price_feed.get_price_unchecked() + }; Ok(Self { ema_price: Box::new(ema_price), diff --git a/tests/07_deposit.spec.ts b/tests/07_deposit.spec.ts index 3837d61c6..0fe8fa33e 100644 --- a/tests/07_deposit.spec.ts +++ b/tests/07_deposit.spec.ts @@ -26,6 +26,7 @@ import { assert } from "chai"; import { depositIx } from "./utils/user-instructions"; import { USER_ACCOUNT } from "./utils/mocks"; import { createMintToInstruction } from "@solana/spl-token"; +import { deriveLiquidityVault } from "./utils/pdas"; describe("Deposit funds", () => { const program = workspace.Marginfi as Program; @@ -65,21 +66,30 @@ describe("Deposit funds", () => { }); it("(user 0) deposit token A to bank - happy path", async () => { - const userABefore = await getTokenBalance(provider, users[0].tokenAAccount); + const user = users[0]; + const [bankLiquidityVault] = deriveLiquidityVault( + program.programId, + bankKeypairA.publicKey + ); + const [userABefore, vaultABefore] = await Promise.all([ + getTokenBalance(provider, user.tokenAAccount), + getTokenBalance(provider, bankLiquidityVault), + ]); if (verbose) { console.log("user 0 A before: " + userABefore.toLocaleString()); + console.log("vault A before: " + vaultABefore.toLocaleString()); } - const user0Account = users[0].accounts.get(USER_ACCOUNT); + const user0Account = user.accounts.get(USER_ACCOUNT); await users[0].userMarginProgram.provider.sendAndConfirm( new Transaction().add( await depositIx(program, { marginfiGroup: marginfiGroup.publicKey, marginfiAccount: user0Account, - authority: users[0].wallet.publicKey, + authority: user.wallet.publicKey, bank: bankKeypairA.publicKey, - tokenAccount: users[0].tokenAAccount, + tokenAccount: user.tokenAAccount, amount: depositAmountA_native, }) ) @@ -96,32 +106,36 @@ describe("Deposit funds", () => { let now = Math.floor(Date.now() / 1000); assertBNApproximately(balances[0].lastUpdate, now, 2); - const userAAfter = await getTokenBalance(provider, users[0].tokenAAccount); + const [userAAfter, vaultAAfter] = await Promise.all([ + getTokenBalance(provider, user.tokenAAccount), + getTokenBalance(provider, bankLiquidityVault), + ]); if (verbose) { - console.log("user 0 A after: " + userABefore.toLocaleString()); + console.log("user 0 A after: " + userAAfter.toLocaleString()); + console.log("vault A after: " + vaultAAfter.toLocaleString()); } assert.equal(userABefore - depositAmountA_native.toNumber(), userAAfter); + // TODO this will change when origination fees are implemented. + assert.equal(vaultABefore + depositAmountA_native.toNumber(), vaultAAfter); }); it("(user 1) deposit USDC to bank - happy path", async () => { - const userUsdcBefore = await getTokenBalance( - provider, - users[1].usdcAccount - ); + const user = users[1]; + const userUsdcBefore = await getTokenBalance(provider, user.usdcAccount); if (verbose) { - console.log("user 1 usdc before: " + userUsdcBefore.toLocaleString()); + console.log("user 1 USDC before: " + userUsdcBefore.toLocaleString()); } - const user1Account = users[1].accounts.get(USER_ACCOUNT); + const user1Account = user.accounts.get(USER_ACCOUNT); await users[1].userMarginProgram.provider.sendAndConfirm( new Transaction().add( await depositIx(program, { marginfiGroup: marginfiGroup.publicKey, marginfiAccount: user1Account, - authority: users[1].wallet.publicKey, + authority: user.wallet.publicKey, bank: bankKeypairUsdc.publicKey, - tokenAccount: users[1].usdcAccount, + tokenAccount: user.usdcAccount, amount: depositAmountUsdc_native, }) ) @@ -138,9 +152,9 @@ describe("Deposit funds", () => { let now = Math.floor(Date.now() / 1000); assertBNApproximately(balances[0].lastUpdate, now, 2); - const userUsdcAfter = await getTokenBalance(provider, users[1].usdcAccount); + const userUsdcAfter = await getTokenBalance(provider, user.usdcAccount); if (verbose) { - console.log("user 1 usdc after: " + userUsdcBefore.toLocaleString()); + console.log("user 1 USDC after: " + userUsdcAfter.toLocaleString()); } assert.equal( userUsdcBefore - depositAmountUsdc_native.toNumber(), diff --git a/tests/08_borrow.spec.ts b/tests/08_borrow.spec.ts new file mode 100644 index 000000000..cb96a3c2d --- /dev/null +++ b/tests/08_borrow.spec.ts @@ -0,0 +1,130 @@ +import { + AnchorProvider, + BN, + getProvider, + Program, + Wallet, + workspace, +} from "@coral-xyz/anchor"; +import { Transaction } from "@solana/web3.js"; +import { Marginfi } from "../target/types/marginfi"; +import { + bankKeypairA, + bankKeypairUsdc, + ecosystem, + marginfiGroup, + oracles, + users, + verbose, +} from "./rootHooks"; +import { + assertBNApproximately, + assertI80F48Approx, + assertI80F48Equal, + getTokenBalance, +} from "./utils/genericTests"; +import { assert } from "chai"; +import { borrowIx, depositIx } from "./utils/user-instructions"; +import { USER_ACCOUNT } from "./utils/mocks"; +import { createMintToInstruction } from "@solana/spl-token"; +import { updatePriceAccount } from "./utils/pyth_mocks"; + +describe("Borrow funds", () => { + const program = workspace.Marginfi as Program; + const provider = getProvider() as AnchorProvider; + const wallet = provider.wallet as Wallet; + + // Bank has 100 USDC available to borrow + // User has 2 Token A (worth $20) deposited + const borrowAmountUsdc = 5; + const borrowAmountUsdc_native = new BN( + borrowAmountUsdc * 10 ** ecosystem.usdcDecimals + ); + + it("Oracle data refreshes", async () => { + const usdcPrice = BigInt(oracles.usdcPrice * 10 ** oracles.usdcDecimals); + await updatePriceAccount( + oracles.usdcOracle, + { + exponent: -oracles.usdcDecimals, + aggregatePriceInfo: { + price: usdcPrice, + conf: usdcPrice / BigInt(100), // 1% of the price + }, + twap: { + // aka ema + valueComponent: usdcPrice, + }, + }, + wallet + ); + + const tokenAPrice = BigInt( + oracles.tokenAPrice * 10 ** oracles.tokenADecimals + ); + await updatePriceAccount( + oracles.tokenAOracle, + { + exponent: -oracles.tokenADecimals, + aggregatePriceInfo: { + price: tokenAPrice, + conf: tokenAPrice / BigInt(100), // 1% of the price + }, + twap: { + // aka ema + valueComponent: tokenAPrice, + }, + }, + wallet + ); + }); + + it("(user 0) borrows USDC against their token A position - happy path", async () => { + const user = users[0]; + const userUsdcBefore = await getTokenBalance(provider, user.usdcAccount); + if (verbose) { + console.log("user 0 USDC before: " + userUsdcBefore.toLocaleString()); + } + + const user0Account = user.accounts.get(USER_ACCOUNT); + + await users[0].userMarginProgram.provider.sendAndConfirm( + new Transaction().add( + await borrowIx(program, { + marginfiGroup: marginfiGroup.publicKey, + marginfiAccount: user0Account, + authority: user.wallet.publicKey, + bank: bankKeypairUsdc.publicKey, + tokenAccount: user.usdcAccount, + remaining: [ + bankKeypairA.publicKey, + oracles.tokenAOracle.publicKey, + bankKeypairUsdc.publicKey, + oracles.usdcOracle.publicKey, + ], + amount: borrowAmountUsdc_native, + }) + ) + ); + + const userAcc = await program.account.marginfiAccount.fetch(user0Account); + const balances = userAcc.lendingAccount.balances; + assert.equal(balances[1].active, true); + assertI80F48Equal(balances[1].assetShares, 0); + // Note: The first borrow issues shares 1:1 and the shares use the same decimals + assertI80F48Approx(balances[1].liabilityShares, borrowAmountUsdc_native); + assertI80F48Equal(balances[1].emissionsOutstanding, 0); + + let now = Math.floor(Date.now() / 1000); + assertBNApproximately(balances[1].lastUpdate, now, 2); + + const userUsdcAfter = await getTokenBalance(provider, user.usdcAccount); + if (verbose) { + console.log("user 0 USDC after: " + userUsdcAfter.toLocaleString()); + } + assert.equal( + userUsdcAfter - borrowAmountUsdc_native.toNumber(), + userUsdcBefore + ); + }); +}); diff --git a/tests/rootHooks.ts b/tests/rootHooks.ts index 0d91fddf2..fe1b35f90 100644 --- a/tests/rootHooks.ts +++ b/tests/rootHooks.ts @@ -218,7 +218,7 @@ const addValidator = (validator: Validator) => { const addUser = (user: MockUser) => { users.push(user); - // copyKeys.push(user.tokenAAccount); + copyKeys.push(user.tokenAAccount); // copyKeys.push(user.tokenBAccount); copyKeys.push(user.usdcAccount); copyKeys.push(user.wallet.publicKey); @@ -227,9 +227,7 @@ const addUser = (user: MockUser) => { /** * Create a mock validator with given vote/withdraw authority - * - * * Note: Spl Pool fields are initialized to pubkey default. - * + * * Note: Spl Pool fields (splPool, mint, authority, stake) are initialized to pubkey default. * @param provider * @param authorizedVoter - also pays init fees * @param authorizedWithdrawer - also pays init fees @@ -305,7 +303,7 @@ export const createSplStakePool = async ( // @ts-ignore // Doesn't matter await provider.sendAndConfirm(tx, [users[0].wallet]); - // Note: you can import the id from @solana/spl-single-pool (the classic version doesn't have it) + // Note: import the id from @solana/spl-single-pool (the classic version doesn't have it) const poolKey = await findPoolAddress( SINGLE_POOL_PROGRAM_ID, validator.voteAccount @@ -337,7 +335,7 @@ export const createSplStakePool = async ( poolKey ); validator.splAuthority = poolAuthority; - // Note: accounts that do not exist (blank PDAs) cannot be pushed) + // Note: accounts that do not exist (blank PDAs) cannot be pushed to bankrun // copyKeys.push(poolAuthority); return { poolKey, poolMintKey, poolAuthority, poolStake }; diff --git a/tests/utils/pyth_mocks.ts b/tests/utils/pyth_mocks.ts index 929a9c755..23ef1b94c 100644 --- a/tests/utils/pyth_mocks.ts +++ b/tests/utils/pyth_mocks.ts @@ -1,4 +1,9 @@ -// Adapted from PsyLend +// TODO the Price struct has changed a bit since this copy-pasta was generated some time ago, +// however price and ema price/expo/conf are in the same spot, so if those are all you need, there's +// no need to update (all modern changes are backwards compatible, new versions of Pyth on-chain +// will still deserialize the price data) + +// Adapated from PsyLend, Jet labs, etc import { Program, Wallet, workspace } from "@coral-xyz/anchor"; import { Keypair, PublicKey } from "@solana/web3.js"; import { Oracles, createMockAccount, storeMockAccount } from "./mocks"; @@ -289,6 +294,10 @@ export const writeProductBuffer = ( * @param wsolDecimals * @param usdcPrice * @param usdcDecimals + * @param tokenAPrice: + * @param tokenADecimals: + * @param tokenBPrice: + * @param tokenBDecimals: * @param verbose * @param skips - set to true to skip sending txes, which makes tests run faster if you don't need * those oracles. diff --git a/tests/utils/user-instructions.ts b/tests/utils/user-instructions.ts index 743a67243..59f624952 100644 --- a/tests/utils/user-instructions.ts +++ b/tests/utils/user-instructions.ts @@ -1,5 +1,5 @@ import { BN, Program } from "@coral-xyz/anchor"; -import { PublicKey } from "@solana/web3.js"; +import { AccountMeta, PublicKey } from "@solana/web3.js"; import { Marginfi } from "../../target/types/marginfi"; import { TOKEN_PROGRAM_ID } from "@solana/spl-token"; @@ -61,10 +61,53 @@ export const depositIx = (program: Program, args: DepositArgs) => { signer: args.authority, bank: args.bank, signerTokenAccount: args.tokenAccount, - // liquidityVault = deriveLiquidityVault(id, bank) + // bankLiquidityVault = deriveLiquidityVault(id, bank) tokenProgram: TOKEN_PROGRAM_ID, }) .instruction(); return ix; }; + +export type BorrowIxArgs = { + marginfiGroup: PublicKey; + marginfiAccount: PublicKey; + authority: PublicKey; + bank: PublicKey; + tokenAccount: PublicKey; + remaining: PublicKey[]; + amount: BN; +}; + +/** + * Borrow from a bank + * * `authority` - must sign, but does not have to own the `tokenAccount` + * * `remaining` - pass bank/oracles for each bank the user is involved with, in the SAME ORDER they + * appear in userAcc.balances (e.g. `[bank0, oracle0, bank1, oracle1]`) + * @param program + * @param args + * @returns + */ +export const borrowIx = (program: Program, args: BorrowIxArgs) => { + const oracleMeta: AccountMeta[] = args.remaining.map((pubkey) => ({ + pubkey, + isSigner: false, + isWritable: false, + })); + const ix = program.methods + .lendingAccountBorrow(args.amount) + .accounts({ + marginfiGroup: args.marginfiGroup, + marginfiAccount: args.marginfiAccount, + signer: args.authority, + bank: args.bank, + destinationTokenAccount: args.tokenAccount, + // bankLiquidityVaultAuthority = deriveLiquidityVaultAuthority(id, bank); + // bankLiquidityVault = deriveLiquidityVault(id, bank) + tokenProgram: TOKEN_PROGRAM_ID, + }) + .remainingAccounts(oracleMeta) + .instruction(); + + return ix; +}; From 88b68dc86f5feec233b700a5139b0aa95af2f76b Mon Sep 17 00:00:00 2001 From: Jon Gurary Date: Thu, 19 Sep 2024 17:15:00 -0400 Subject: [PATCH 17/52] WIP add asset tag to banks --- programs/marginfi/src/constants.rs | 9 ++ programs/marginfi/src/state/marginfi_group.rs | 42 +++++- .../tests/admin_actions/setup_bank.rs | 2 + programs/marginfi/tests/misc/regression.rs | 14 +- tests/03_addBank.spec.ts | 3 +- tests/04_configureBank.spec.ts | 15 ++- tests/rootHooks.ts | 6 +- tests/s02_addBank.spec.ts | 122 ++++++++++++++++++ tests/utils/group-instructions.ts | 7 +- tests/utils/types.ts | 15 ++- 10 files changed, 211 insertions(+), 24 deletions(-) create mode 100644 tests/s02_addBank.spec.ts diff --git a/programs/marginfi/src/constants.rs b/programs/marginfi/src/constants.rs index e3b67d31f..7199665f5 100644 --- a/programs/marginfi/src/constants.rs +++ b/programs/marginfi/src/constants.rs @@ -139,3 +139,12 @@ pub const TOTAL_ASSET_VALUE_INIT_LIMIT_INACTIVE: u64 = 0; pub const MIN_PYTH_PUSH_VERIFICATION_LEVEL: VerificationLevel = VerificationLevel::Full; pub const PYTH_PUSH_PYTH_SPONSORED_SHARD_ID: u16 = 0; pub const PYTH_PUSH_MARGINFI_SPONSORED_SHARD_ID: u16 = 3301; + +/// A regular asset that can be comingled with any other regular asset or with `ASSET_TAG_SOL` +pub const ASSET_TAG_DEFAULT: u8 = 0; +/// Accounts with a SOL position can comingle with **either** `ASSET_TAG_DEFAULT` or +/// `ASSET_TAG_STAKED` positions, but not both +pub const ASSET_TAG_SOL: u8 = 1; +/// Staked SOL assets. Accounts with a STAKED position can only deposit other STAKED assets or SOL +/// (`ASSET_TAG_SOL`) and can only borrow SOL (`ASSET_TAG_SOL`) +pub const ASSET_TAG_STAKED: u8 = 2; diff --git a/programs/marginfi/src/state/marginfi_group.rs b/programs/marginfi/src/state/marginfi_group.rs index ead16d9d7..5dbba8015 100644 --- a/programs/marginfi/src/state/marginfi_group.rs +++ b/programs/marginfi/src/state/marginfi_group.rs @@ -2,7 +2,6 @@ use super::{ marginfi_account::{BalanceSide, RequirementType}, price::{OraclePriceFeedAdapter, OracleSetup}, }; -use crate::borsh::{BorshDeserialize, BorshSerialize}; #[cfg(not(feature = "client"))] use crate::events::{GroupEventHeader, LendingPoolBankAccrueInterestEvent}; use crate::{ @@ -20,6 +19,10 @@ use crate::{ state::marginfi_account::calc_value, MarginfiResult, }; +use crate::{ + borsh::{BorshDeserialize, BorshSerialize}, + constants::ASSET_TAG_DEFAULT, +}; use anchor_lang::prelude::borsh; use anchor_lang::prelude::*; use anchor_spl::token_interface::*; @@ -547,6 +550,8 @@ impl Bank { set_if_some!(self.config.risk_tier, config.risk_tier); + set_if_some!(self.config.asset_tag, config.asset_tag); + set_if_some!( self.config.total_asset_value_init_limit, config.total_asset_value_init_limit @@ -978,7 +983,17 @@ pub struct BankConfigCompact { pub risk_tier: RiskTier, - pub _pad0: [u8; 7], + /// Determines what kinds of assets users of this bank can interact with. + /// Options: + /// * ASSET_TAG_DEFAULT (0) - A regular asset that can be comingled with any other regular asset + /// or with `ASSET_TAG_SOL` + /// * ASSET_TAG_SOL (1) - Accounts with a SOL position can comingle with **either** + /// `ASSET_TAG_DEFAULT` or `ASSET_TAG_STAKED` positions, but not both + /// * ASSET_TAG_STAKED (2) - Staked SOL assets. Accounts with a STAKED position can only deposit + /// other STAKED assets or SOL (`ASSET_TAG_SOL`) and can only borrow SOL + pub asset_tag: u8, + + pub _pad0: [u8; 6], /// USD denominated limit for calculating asset value for initialization margin requirements. /// Example, if total SOL deposits are equal to $1M and the limit it set to $500K, @@ -1016,7 +1031,8 @@ impl From for BankConfig { _pad0: [0; 6], borrow_limit: config.borrow_limit, risk_tier: config.risk_tier, - _pad1: [0; 7], + asset_tag: ASSET_TAG_DEFAULT, + _pad1: [0; 6], total_asset_value_init_limit: config.total_asset_value_init_limit, oracle_max_age: config.oracle_max_age, _padding: [0; 38], @@ -1038,7 +1054,8 @@ impl From for BankConfigCompact { oracle_key: config.oracle_keys[0], borrow_limit: config.borrow_limit, risk_tier: config.risk_tier, - _pad0: [0; 7], + asset_tag: ASSET_TAG_DEFAULT, + _pad0: [0; 6], total_asset_value_init_limit: config.total_asset_value_init_limit, oracle_max_age: config.oracle_max_age, } @@ -1077,7 +1094,17 @@ pub struct BankConfig { pub risk_tier: RiskTier, - pub _pad1: [u8; 7], + /// Determines what kinds of assets users of this bank can interact with. + /// Options: + /// * ASSET_TAG_DEFAULT (0) - A regular asset that can be comingled with any other regular asset + /// or with `ASSET_TAG_SOL` + /// * ASSET_TAG_SOL (1) - Accounts with a SOL position can comingle with **either** + /// `ASSET_TAG_DEFAULT` or `ASSET_TAG_STAKED` positions, but not both + /// * ASSET_TAG_STAKED (2) - Staked SOL assets. Accounts with a STAKED position can only deposit + /// other STAKED assets or SOL (`ASSET_TAG_SOL`) and can only borrow SOL + pub asset_tag: u8, + + pub _pad1: [u8; 6], /// USD denominated limit for calculating asset value for initialization margin requirements. /// Example, if total SOL deposits are equal to $1M and the limit it set to $500K, @@ -1110,7 +1137,8 @@ impl Default for BankConfig { oracle_keys: [Pubkey::default(); MAX_ORACLE_KEYS], _pad0: [0; 6], risk_tier: RiskTier::Isolated, - _pad1: [0; 7], + asset_tag: ASSET_TAG_DEFAULT, + _pad1: [0; 6], total_asset_value_init_limit: TOTAL_ASSET_VALUE_INIT_LIMIT_INACTIVE, oracle_max_age: 0, _padding: [0; 38], @@ -1274,6 +1302,8 @@ pub struct BankConfigOpt { pub risk_tier: Option, + pub asset_tag: Option, + pub total_asset_value_init_limit: Option, pub oracle_max_age: Option, diff --git a/programs/marginfi/tests/admin_actions/setup_bank.rs b/programs/marginfi/tests/admin_actions/setup_bank.rs index 2d7eade8a..09629600c 100644 --- a/programs/marginfi/tests/admin_actions/setup_bank.rs +++ b/programs/marginfi/tests/admin_actions/setup_bank.rs @@ -280,6 +280,7 @@ async fn configure_bank_success(bank_mint: BankMint) -> anyhow::Result<()> { operational_state, oracle, risk_tier, + asset_tag, total_asset_value_init_limit, oracle_max_age, permissionless_bad_debt_settlement, @@ -322,6 +323,7 @@ async fn configure_bank_success(bank_mint: BankMint) -> anyhow::Result<()> { check_bank_field!(borrow_limit); check_bank_field!(operational_state); check_bank_field!(risk_tier); + check_bank_field!(asset_tag); check_bank_field!(total_asset_value_init_limit); check_bank_field!(oracle_max_age); diff --git a/programs/marginfi/tests/misc/regression.rs b/programs/marginfi/tests/misc/regression.rs index a8b855f26..2773f0208 100644 --- a/programs/marginfi/tests/misc/regression.rs +++ b/programs/marginfi/tests/misc/regression.rs @@ -4,10 +4,13 @@ use anchor_lang::AccountDeserialize; use anyhow::bail; use base64::{prelude::BASE64_STANDARD, Engine}; use fixed::types::I80F48; -use marginfi::state::{ - marginfi_account::MarginfiAccount, - marginfi_group::{Bank, BankOperationalState, RiskTier}, - price::OracleSetup, +use marginfi::{ + constants::ASSET_TAG_DEFAULT, + state::{ + marginfi_account::MarginfiAccount, + marginfi_group::{Bank, BankOperationalState, RiskTier}, + price::OracleSetup, + }, }; use solana_account_decoder::UiAccountData; use solana_cli_output::CliAccount; @@ -635,7 +638,8 @@ async fn bank_field_values_reg() -> anyhow::Result<()> { assert_eq!(bank.config._pad0, [0; 6]); assert_eq!(bank.config.borrow_limit, 2000000000000); assert_eq!(bank.config.risk_tier, RiskTier::Collateral); - assert_eq!(bank.config._pad1, [0; 7]); + assert_eq!(bank.config.asset_tag, ASSET_TAG_DEFAULT); + assert_eq!(bank.config._pad1, [0; 6]); assert_eq!(bank.config.total_asset_value_init_limit, 0); assert_eq!(bank.config.oracle_max_age, 300); assert_eq!(bank.config._padding, [0; 38]); diff --git a/tests/03_addBank.spec.ts b/tests/03_addBank.spec.ts index 452a8fdf0..9d323b737 100644 --- a/tests/03_addBank.spec.ts +++ b/tests/03_addBank.spec.ts @@ -19,7 +19,7 @@ import { assertKeyDefault, assertKeysEqual, } from "./utils/genericTests"; -import { defaultBankConfig } from "./utils/types"; +import { ASSET_TAG_DEFAULT, defaultBankConfig } from "./utils/types"; import { deriveLiquidityVaultAuthority, deriveLiquidityVault, @@ -129,6 +129,7 @@ describe("Lending pool add bank (add bank to group)", () => { assert.deepEqual(config.oracleSetup, { pythLegacy: {} }); assertBNEqual(config.borrowLimit, 100_000_000_000); assert.deepEqual(config.riskTier, { collateral: {} }); + assert.equal(config.assetTag, ASSET_TAG_DEFAULT); assertBNEqual(config.totalAssetValueInitLimit, 1_000_000_000_000); assert.equal(config.oracleMaxAge, 100); }); diff --git a/tests/04_configureBank.spec.ts b/tests/04_configureBank.spec.ts index 23f4d0590..756a62202 100644 --- a/tests/04_configureBank.spec.ts +++ b/tests/04_configureBank.spec.ts @@ -5,12 +5,13 @@ import { Marginfi } from "../target/types/marginfi"; import { bankKeypairUsdc, groupAdmin, marginfiGroup } from "./rootHooks"; import { assertBNEqual, assertI80F48Approx } from "./utils/genericTests"; import { assert } from "chai"; -import { - BankConfigOptRaw, - InterestRateConfigRaw, -} from "@mrgnlabs/marginfi-client-v2"; +import { InterestRateConfigRaw } from "@mrgnlabs/marginfi-client-v2"; import { bigNumberToWrappedI80F48 } from "@mrgnlabs/mrgn-common"; -import { defaultBankConfigOptRaw } from "./utils/types"; +import { + ASSET_TAG_SOL, + BankConfigOptWithAssetTag, + defaultBankConfigOptRaw, +} from "./utils/types"; describe("Lending pool configure bank", () => { const program = workspace.Marginfi as Program; @@ -27,7 +28,7 @@ describe("Lending pool configure bank", () => { protocolIrFee: bigNumberToWrappedI80F48(0.6), }; - let bankConfigOpt: BankConfigOptRaw = { + let bankConfigOpt: BankConfigOptWithAssetTag = { assetWeightInit: bigNumberToWrappedI80F48(0.6), assetWeightMaint: bigNumberToWrappedI80F48(0.7), liabilityWeightInit: bigNumberToWrappedI80F48(1.9), @@ -35,6 +36,7 @@ describe("Lending pool configure bank", () => { depositLimit: new BN(5000), borrowLimit: new BN(10000), riskTier: null, + assetTag: ASSET_TAG_SOL, totalAssetValueInitLimit: new BN(15000), interestRateConfig: interestRateConfig, operationalState: { @@ -78,6 +80,7 @@ describe("Lending pool configure bank", () => { assert.deepEqual(config.oracleSetup, { pythLegacy: {} }); // no change assertBNEqual(config.borrowLimit, 10000); assert.deepEqual(config.riskTier, { collateral: {} }); // no change + assert.equal(config.assetTag, ASSET_TAG_SOL); assertBNEqual(config.totalAssetValueInitLimit, 15000); assert.equal(config.oracleMaxAge, 50); }); diff --git a/tests/rootHooks.ts b/tests/rootHooks.ts index fe1b35f90..88f584f9e 100644 --- a/tests/rootHooks.ts +++ b/tests/rootHooks.ts @@ -14,7 +14,6 @@ import { Marginfi } from "../target/types/marginfi"; import { Keypair, PublicKey, - StakeProgram, SystemProgram, SYSVAR_STAKE_HISTORY_PUBKEY, Transaction, @@ -53,7 +52,7 @@ export const users: MockUser[] = []; export const numUsers = 2; export const validators: Validator[] = []; -export const numValidators = 2; +export const numValidators = 1; /** Group used for all happy-path tests */ export const marginfiGroup = Keypair.generate(); @@ -161,6 +160,9 @@ export const mochaHooks = { ecosystem.tokenBDecimals, verbose ); + copyKeys.push(oracles.wsolOracle.publicKey); + copyKeys.push(oracles.usdcOracle.publicKey); + copyKeys.push(oracles.tokenAOracle.publicKey); for (let i = 0; i < numValidators; i++) { const validator = await createValidator( diff --git a/tests/s02_addBank.spec.ts b/tests/s02_addBank.spec.ts new file mode 100644 index 000000000..5d5175c48 --- /dev/null +++ b/tests/s02_addBank.spec.ts @@ -0,0 +1,122 @@ +import { BN, Program, workspace } from "@coral-xyz/anchor"; +import { PublicKey, Transaction } from "@solana/web3.js"; +import { addBank, groupInitialize } from "./utils/group-instructions"; +import { Marginfi } from "../target/types/marginfi"; +import { + bankKeypairA, + bankKeypairUsdc, + bankrunContext, + banksClient, + ecosystem, + groupAdmin, + marginfiGroup, + oracles, + printBuffers, + users, + verbose, +} from "./rootHooks"; +import { + assertBNEqual, + assertI80F48Approx, + assertI80F48Equal, + assertKeyDefault, + assertKeysEqual, +} from "./utils/genericTests"; +import { ASSET_TAG_DEFAULT, defaultBankConfig } from "./utils/types"; +import { + deriveLiquidityVaultAuthority, + deriveLiquidityVault, + deriveInsuranceVaultAuthority, + deriveInsuranceVault, + deriveFeeVaultAuthority, + deriveFeeVault, +} from "./utils/pdas"; +import { assert } from "chai"; +import { printBufferGroups } from "./utils/tools"; +import { getBankrunBlockhash } from "./utils/spl-staking-utils"; + +describe("Init group and add banks with asset category flags", () => { + const program = workspace.Marginfi as Program; + + it("(admin) Init group - happy path", async () => { + let tx = new Transaction(); + + tx.add( + await groupInitialize(program, { + marginfiGroup: marginfiGroup.publicKey, + admin: groupAdmin.wallet.publicKey, + }) + ); + tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + tx.sign(groupAdmin.wallet, marginfiGroup); + await banksClient.processTransaction(tx); + + let group = await program.account.marginfiGroup.fetch( + marginfiGroup.publicKey + ); + assertKeysEqual(group.admin, groupAdmin.wallet.publicKey); + if (verbose) { + console.log("*init group: " + marginfiGroup.publicKey); + console.log(" group admin: " + group.admin); + } + }); + + it("(admin) Add bank (USDC) - is neither SOL nor staked LST", async () => { + let setConfig = defaultBankConfig(oracles.usdcOracle.publicKey); + let bankKey = bankKeypairUsdc.publicKey; + const now = Date.now() / 1000; + + let tx = new Transaction(); + + tx.add( + await addBank(program, { + marginfiGroup: marginfiGroup.publicKey, + admin: groupAdmin.wallet.publicKey, + feePayer: groupAdmin.wallet.publicKey, + bankMint: ecosystem.usdcMint.publicKey, + bank: bankKey, + config: setConfig, + }) + ); + tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + tx.sign(groupAdmin.wallet, bankKeypairUsdc); + await banksClient.processTransaction(tx); + + if (verbose) { + console.log("*init USDC bank " + bankKey); + } + + let bankData = ( + await program.provider.connection.getAccountInfo(bankKey) + ).data.subarray(8); + if (printBuffers) { + printBufferGroups(bankData, 16, 896); + } + + const bank = await program.account.bank.fetch(bankKey); + assert.equal(bank.config.assetTag, ASSET_TAG_DEFAULT); + }); + + // it("(admin) Add bank (token A) - happy path", async () => { + // let config = defaultBankConfig(oracles.tokenAOracle.publicKey); + // let bankKey = bankKeypairA.publicKey; + + // await groupAdmin.userMarginProgram!.provider.sendAndConfirm!( + // new Transaction().add( + // await addBank(program, { + // marginfiGroup: marginfiGroup.publicKey, + // admin: groupAdmin.wallet.publicKey, + // feePayer: groupAdmin.wallet.publicKey, + // bankMint: ecosystem.tokenAMint.publicKey, + // bank: bankKey, + // config: config, + // }) + // ), + // [bankKeypairA] + // ); + + // if (verbose) { + // console.log("*init token A bank " + bankKey); + // } + // }); +}); diff --git a/tests/utils/group-instructions.ts b/tests/utils/group-instructions.ts index 298342220..1a1c75822 100644 --- a/tests/utils/group-instructions.ts +++ b/tests/utils/group-instructions.ts @@ -9,7 +9,7 @@ import { deriveLiquidityVault, deriveLiquidityVaultAuthority, } from "./pdas"; -import { BankConfig } from "./types"; +import { BankConfig, BankConfigOptWithAssetTag } from "./types"; import { TOKEN_PROGRAM_ID } from "@solana/spl-token"; import { BankConfigOptRaw } from "@mrgnlabs/marginfi-client-v2"; @@ -52,7 +52,8 @@ export const addBank = (program: Program, args: AddBankArgs) => { oracleKey: args.config.oracleKey, borrowLimit: args.config.borrowLimit, riskTier: args.config.riskTier, - pad0: [0, 0, 0, 0, 0, 0, 0], + assetTag: args.config.assetTag, + pad0: [0, 0, 0, 0, 0, 0], totalAssetValueInitLimit: args.config.totalAssetValueInitLimit, oracleMaxAge: args.config.oracleMaxAge, }) @@ -128,7 +129,7 @@ export type ConfigureBankArgs = { marginfiGroup: PublicKey; admin: PublicKey; bank: PublicKey; - bankConfigOpt: BankConfigOptRaw; + bankConfigOpt: BankConfigOptWithAssetTag; // BankConfigOptRaw + assetTag }; export const configureBank = ( diff --git a/tests/utils/types.ts b/tests/utils/types.ts index a9e72b78e..48caf0bda 100644 --- a/tests/utils/types.ts +++ b/tests/utils/types.ts @@ -26,6 +26,10 @@ export const EMISSIONS_FLAG_NONE = 0; export const EMISSIONS_FLAG_BORROW_ACTIVE = 1; export const EMISSIONS_FLAG_LENDING_ACTIVE = 2; +export const ASSET_TAG_DEFAULT = 0; +export const ASSET_TAG_SOL = 1; +export const ASSET_TAG_STAKED = 2; + type OperationalStateRaw = | { paused: {} } | { operational: {} } @@ -51,6 +55,7 @@ export type BankConfig = { borrowLimit: BN; /** Collateral = 0, Isolated = 1 */ riskTier: RiskTierRaw; + assetTag: number; totalAssetValueInitLimit: BN; oracleMaxAge: number; }; @@ -62,6 +67,7 @@ export type BankConfig = { * * uses the given oracle, assumes it's = pythLegacy * * 100_000_000_000 deposit/borrow limit * * 1_000_000_000_000 total asset value limit + * * asset tag default (`ASSET_TAG_DEFAULT`) * @returns */ export const defaultBankConfig = (oracleKey: PublicKey) => { @@ -83,6 +89,7 @@ export const defaultBankConfig = (oracleKey: PublicKey) => { riskTier: { collateral: undefined, }, + assetTag: ASSET_TAG_DEFAULT, totalAssetValueInitLimit: new BN(1_000_000_000_000), oracleMaxAge: 100, }; @@ -118,7 +125,7 @@ export const defaultBankConfigOpt = () => { * @returns */ export const defaultBankConfigOptRaw = () => { - let bankConfigOpt: BankConfigOptRaw = { + let bankConfigOpt: BankConfigOptWithAssetTag = { assetWeightInit: I80F48_ONE, assetWeightMaint: I80F48_ONE, liabilityWeightInit: I80F48_ONE, @@ -128,6 +135,7 @@ export const defaultBankConfigOptRaw = () => { riskTier: { collateral: undefined, }, + assetTag: ASSET_TAG_DEFAULT, totalAssetValueInitLimit: new BN(100_000_000_000), interestRateConfig: defaultInterestRateConfigRaw(), operationalState: { @@ -178,3 +186,8 @@ export const defaultInterestRateConfig = () => { }; return config; }; + +// TODO remove when package updates +export type BankConfigOptWithAssetTag = BankConfigOptRaw & { + assetTag: number | null; +}; From 364034c9c9a82e6fd37b9ceaa16a5eedfeb2cb04 Mon Sep 17 00:00:00 2001 From: Jon Gurary Date: Fri, 20 Sep 2024 16:55:35 -0400 Subject: [PATCH 18/52] Complete asset tag feature, validate that Staked accounts cannot comingle with regular ones --- Anchor.toml | 4 +- clients/rust/marginfi-cli/src/entrypoint.rs | 4 + programs/marginfi/src/errors.rs | 12 +- .../instructions/marginfi_account/borrow.rs | 4 +- .../instructions/marginfi_account/deposit.rs | 6 +- .../marginfi_group/add_pool_permissionless.rs | 214 ++++++++++++++++ .../src/instructions/marginfi_group/mod.rs | 2 + .../marginfi/src/state/marginfi_account.rs | 18 +- programs/marginfi/src/state/marginfi_group.rs | 4 +- programs/marginfi/src/utils.rs | 36 ++- programs/marginfi/tests/misc/regression.rs | 15 +- tests/rootHooks.ts | 28 ++- tests/s01_usersStake.spec.ts | 65 ++++- tests/s02_addBank.spec.ts | 119 +++++---- tests/s03_deposit.spec.ts | 236 ++++++++++++++++++ tests/s04_borrow.spec.ts | 121 +++++++++ tests/utils/genericTests.ts | 17 ++ tests/utils/mocks.ts | 6 + 18 files changed, 820 insertions(+), 91 deletions(-) create mode 100644 programs/marginfi/src/instructions/marginfi_group/add_pool_permissionless.rs create mode 100644 tests/s03_deposit.spec.ts create mode 100644 tests/s04_borrow.spec.ts diff --git a/Anchor.toml b/Anchor.toml index c39bd0ee7..5f53fad36 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -25,10 +25,10 @@ wallet = "~/.config/solana/id.json" # (remove RUST_LOG= to see bankRun logs) [scripts] -test = "RUST_LOG= yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/*.spec.ts --exit --require tests/rootHooks.ts" +# test = "RUST_LOG= yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/*.spec.ts --exit --require tests/rootHooks.ts" # Staking Collatizer only -# test = "RUST_LOG= yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/s*.spec.ts --exit --require tests/rootHooks.ts" +test = "RUST_LOG= yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/s*.spec.ts --exit --require tests/rootHooks.ts" [test] startup_wait = 5000 diff --git a/clients/rust/marginfi-cli/src/entrypoint.rs b/clients/rust/marginfi-cli/src/entrypoint.rs index 3c54a7267..e00c91a3c 100644 --- a/clients/rust/marginfi-cli/src/entrypoint.rs +++ b/clients/rust/marginfi-cli/src/entrypoint.rs @@ -255,6 +255,8 @@ pub enum BankCommand { pf_ir: Option, #[clap(long, arg_enum, help = "Bank risk tier")] risk_tier: Option, + #[clap(long, help = "0 = default, 1 = SOL, 2 = Staked SOL LST")] + asset_tag: Option, #[clap(long, arg_enum, help = "Bank oracle type")] oracle_type: Option, #[clap(long, help = "Bank oracle account")] @@ -662,6 +664,7 @@ fn bank(subcmd: BankCommand, global_options: &GlobalOptions) -> Result<()> { pf_fa, pf_ir, risk_tier, + asset_tag, oracle_type, oracle_key, usd_init_limit, @@ -712,6 +715,7 @@ fn bank(subcmd: BankCommand, global_options: &GlobalOptions) -> Result<()> { protocol_ir_fee: pf_ir.map(|x| I80F48::from_num(x).into()), }), risk_tier: risk_tier.map(|x| x.into()), + asset_tag, total_asset_value_init_limit: usd_init_limit, oracle_max_age, permissionless_bad_debt_settlement, diff --git a/programs/marginfi/src/errors.rs b/programs/marginfi/src/errors.rs index b68837b47..1059fdaa7 100644 --- a/programs/marginfi/src/errors.rs +++ b/programs/marginfi/src/errors.rs @@ -86,16 +86,18 @@ pub enum MarginfiError { IllegalFlashloan, #[msg("Illegal flag")] // 6041 IllegalFlag, - #[msg("Illegal balance state")] // 6043 + #[msg("Illegal balance state")] // 6042 IllegalBalanceState, - #[msg("Illegal account authority transfer")] // 6044 + #[msg("Illegal account authority transfer")] // 6043 IllegalAccountAuthorityTransfer, - #[msg("Unauthorized")] // 6045 + #[msg("Unauthorized")] // 6044 Unauthorized, - #[msg("Invalid account authority")] // 6046 + #[msg("Invalid account authority")] // 6045 IllegalAction, - #[msg("Token22 Banks require mint account as first remaining account")] // 6047 + #[msg("Token22 Banks require mint account as first remaining account")] // 6046 T22MintRequired, + #[msg("Staked SOL accounts can only deposit staked assets and borrow SOL")] // 6047 + AssetTagMismatch, } impl From for ProgramError { diff --git a/programs/marginfi/src/instructions/marginfi_account/borrow.rs b/programs/marginfi/src/instructions/marginfi_account/borrow.rs index 6f6952f72..a3259205e 100644 --- a/programs/marginfi/src/instructions/marginfi_account/borrow.rs +++ b/programs/marginfi/src/instructions/marginfi_account/borrow.rs @@ -7,7 +7,7 @@ use crate::{ marginfi_account::{BankAccountWrapper, MarginfiAccount, RiskEngine, DISABLED_FLAG}, marginfi_group::{Bank, BankVaultType}, }, - utils, + utils::{self, validate_asset_tags}, }; use anchor_lang::prelude::*; use anchor_spl::token_interface::{TokenAccount, TokenInterface}; @@ -57,6 +57,8 @@ pub fn lending_account_borrow<'info>( { let mut bank = bank_loader.load_mut()?; + validate_asset_tags(&bank, &marginfi_account)?; + let liquidity_vault_authority_bump = bank.liquidity_vault_authority_bump; let mut bank_account = BankAccountWrapper::find_or_create( diff --git a/programs/marginfi/src/instructions/marginfi_account/deposit.rs b/programs/marginfi/src/instructions/marginfi_account/deposit.rs index 5855bd3d7..16ef18655 100644 --- a/programs/marginfi/src/instructions/marginfi_account/deposit.rs +++ b/programs/marginfi/src/instructions/marginfi_account/deposit.rs @@ -1,13 +1,13 @@ use crate::{ check, - constants::LIQUIDITY_VAULT_SEED, + constants::{ASSET_TAG_DEFAULT, ASSET_TAG_SOL, ASSET_TAG_STAKED, LIQUIDITY_VAULT_SEED}, events::{AccountEventHeader, LendingAccountDepositEvent}, prelude::*, state::{ marginfi_account::{BankAccountWrapper, MarginfiAccount, DISABLED_FLAG}, marginfi_group::Bank, }, - utils, + utils::{self, validate_asset_tags}, }; use anchor_lang::prelude::*; use anchor_spl::token_interface::TokenInterface; @@ -44,6 +44,8 @@ pub fn lending_account_deposit<'info>( let mut bank = bank_loader.load_mut()?; let mut marginfi_account = marginfi_account_loader.load_mut()?; + validate_asset_tags(&bank, &marginfi_account)?; + check!( !marginfi_account.get_flag(DISABLED_FLAG), MarginfiError::AccountDisabled diff --git a/programs/marginfi/src/instructions/marginfi_group/add_pool_permissionless.rs b/programs/marginfi/src/instructions/marginfi_group/add_pool_permissionless.rs new file mode 100644 index 000000000..06cab04c8 --- /dev/null +++ b/programs/marginfi/src/instructions/marginfi_group/add_pool_permissionless.rs @@ -0,0 +1,214 @@ +// Adds a ASSET_TAG_STAKED type bank to a group with sane defaults. Used by validators to add their +// freshly-minted LST to a group so users can borrow SOL against it + +// TODO should we support this for riskTier::Isolated too? + +// TODO pick a hardcoded oracle + +// TODO pick a hardcoded interest regmine + +// TODO pick a hardcoded asset weight (~85%?) and `total_asset_value_init_limit` + +// TODO pick a hardcoded max oracle age (~30s?) + +// TODO pick a hardcoded initial deposit limit () + +// TODO should the group admin need to opt in to this functionality (configure the group)? We could +// also configure the key that assumes default admin here instead of using the group's admin +use crate::{ + constants::{ + ASSET_TAG_STAKED, FEE_VAULT_AUTHORITY_SEED, FEE_VAULT_SEED, INSURANCE_VAULT_AUTHORITY_SEED, + INSURANCE_VAULT_SEED, LIQUIDITY_VAULT_AUTHORITY_SEED, LIQUIDITY_VAULT_SEED, + }, + events::{GroupEventHeader, LendingPoolBankCreateEvent}, + state::{ + marginfi_group::{ + Bank, BankConfigCompact, BankOperationalState, InterestRateConfig, + MarginfiGroup, RiskTier, + }, + price::OracleSetup, + }, + MarginfiResult, +}; +use anchor_lang::prelude::*; +use anchor_spl::token_interface::*; +use fixed_macro::types::I80F48; + +pub fn lending_pool_add_bank_permissionless( + ctx: Context, + _bank_seed: u64, +) -> MarginfiResult { + let LendingPoolAddBankPermissionless { + bank_mint, + liquidity_vault, + insurance_vault, + fee_vault, + bank: bank_loader, + .. + } = ctx.accounts; + + let mut bank = bank_loader.load_init()?; + let group = ctx.accounts.marginfi_group.load()?; + + let liquidity_vault_bump = ctx.bumps.liquidity_vault; + let liquidity_vault_authority_bump = ctx.bumps.liquidity_vault_authority; + let insurance_vault_bump = ctx.bumps.insurance_vault; + let insurance_vault_authority_bump = ctx.bumps.insurance_vault_authority; + let fee_vault_bump = ctx.bumps.fee_vault; + let fee_vault_authority_bump = ctx.bumps.fee_vault_authority; + + let default_ir_config = InterestRateConfig { + optimal_utilization_rate: I80F48!(0.4).into(), + plateau_interest_rate: I80F48!(0.4).into(), + protocol_fixed_fee_apr: I80F48!(0.01).into(), + max_interest_rate: I80F48!(3).into(), + insurance_ir_fee: I80F48!(0.1).into(), + ..Default::default() + }; + + let default_config: BankConfigCompact = BankConfigCompact { + asset_weight_init: I80F48!(0.5).into(), + asset_weight_maint: I80F48!(0.75).into(), + liability_weight_init: I80F48!(1.5).into(), + liability_weight_maint: I80F48!(1.25).into(), + deposit_limit: 42, + interest_rate_config: default_ir_config.into(), + operational_state: BankOperationalState::Operational, + oracle_setup: OracleSetup::PythLegacy, + oracle_key: Pubkey::new_unique(), + borrow_limit: 0, + risk_tier: RiskTier::Collateral, + asset_tag: ASSET_TAG_STAKED, + _pad0: [0; 6], + total_asset_value_init_limit: 42, + oracle_max_age: 10, + }; + + *bank = Bank::new( + ctx.accounts.marginfi_group.key(), + default_config.into(), + bank_mint.key(), + bank_mint.decimals, + liquidity_vault.key(), + insurance_vault.key(), + fee_vault.key(), + Clock::get().unwrap().unix_timestamp, + liquidity_vault_bump, + liquidity_vault_authority_bump, + insurance_vault_bump, + insurance_vault_authority_bump, + fee_vault_bump, + fee_vault_authority_bump, + ); + + bank.config.validate()?; + bank.config.validate_oracle_setup(ctx.remaining_accounts)?; + + emit!(LendingPoolBankCreateEvent { + header: GroupEventHeader { + marginfi_group: ctx.accounts.marginfi_group.key(), + signer: Some(group.admin) + }, + bank: bank_loader.key(), + mint: bank_mint.key(), + }); + + Ok(()) +} + +#[derive(Accounts)] +#[instruction(bank_seed: u64)] +pub struct LendingPoolAddBankPermissionless<'info> { + pub marginfi_group: AccountLoader<'info, MarginfiGroup>, + + #[account(mut)] + pub fee_payer: Signer<'info>, + + pub bank_mint: Box>, + + #[account( + init, + space = 8 + std::mem::size_of::(), + payer = fee_payer, + seeds = [ + marginfi_group.key().as_ref(), + bank_mint.key().as_ref(), + &bank_seed.to_le_bytes(), + ], + bump, + )] + pub bank: AccountLoader<'info, Bank>, + + /// CHECK: ⋐ ͡⋄ ω ͡⋄ ⋑ + #[account( + seeds = [ + LIQUIDITY_VAULT_AUTHORITY_SEED.as_bytes(), + bank.key().as_ref(), + ], + bump + )] + pub liquidity_vault_authority: AccountInfo<'info>, + + #[account( + init, + payer = fee_payer, + token::mint = bank_mint, + token::authority = liquidity_vault_authority, + seeds = [ + LIQUIDITY_VAULT_SEED.as_bytes(), + bank.key().as_ref(), + ], + bump, + )] + pub liquidity_vault: Box>, + + /// CHECK: ⋐ ͡⋄ ω ͡⋄ ⋑ + #[account( + seeds = [ + INSURANCE_VAULT_AUTHORITY_SEED.as_bytes(), + bank.key().as_ref(), + ], + bump + )] + pub insurance_vault_authority: AccountInfo<'info>, + + #[account( + init, + payer = fee_payer, + token::mint = bank_mint, + token::authority = insurance_vault_authority, + seeds = [ + INSURANCE_VAULT_SEED.as_bytes(), + bank.key().as_ref(), + ], + bump, + )] + pub insurance_vault: Box>, + + /// CHECK: ⋐ ͡⋄ ω ͡⋄ ⋑ + #[account( + seeds = [ + FEE_VAULT_AUTHORITY_SEED.as_bytes(), + bank.key().as_ref(), + ], + bump + )] + pub fee_vault_authority: AccountInfo<'info>, + + #[account( + init, + payer = fee_payer, + token::mint = bank_mint, + token::authority = fee_vault_authority, + seeds = [ + FEE_VAULT_SEED.as_bytes(), + bank.key().as_ref(), + ], + bump, + )] + pub fee_vault: Box>, + + pub rent: Sysvar<'info, Rent>, + pub token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, +} diff --git a/programs/marginfi/src/instructions/marginfi_group/mod.rs b/programs/marginfi/src/instructions/marginfi_group/mod.rs index 33bc6a914..4d00f4854 100644 --- a/programs/marginfi/src/instructions/marginfi_group/mod.rs +++ b/programs/marginfi/src/instructions/marginfi_group/mod.rs @@ -5,6 +5,7 @@ mod configure; mod configure_bank; mod handle_bankruptcy; mod initialize; +mod add_pool_permissionless; pub use accrue_bank_interest::*; pub use add_pool::*; @@ -13,3 +14,4 @@ pub use configure::*; pub use configure_bank::*; pub use handle_bankruptcy::*; pub use initialize::*; +pub use add_pool_permissionless::*; \ No newline at end of file diff --git a/programs/marginfi/src/state/marginfi_account.rs b/programs/marginfi/src/state/marginfi_account.rs index f18fe143e..0186c2f39 100644 --- a/programs/marginfi/src/state/marginfi_account.rs +++ b/programs/marginfi/src/state/marginfi_account.rs @@ -5,9 +5,7 @@ use super::{ use crate::{ assert_struct_align, assert_struct_size, check, constants::{ - BANKRUPT_THRESHOLD, EMISSIONS_FLAG_BORROW_ACTIVE, EMISSIONS_FLAG_LENDING_ACTIVE, - EMPTY_BALANCE_THRESHOLD, EXP_10_I80F48, MIN_EMISSIONS_START_TIME, SECONDS_PER_YEAR, - ZERO_AMOUNT_THRESHOLD, + ASSET_TAG_DEFAULT, BANKRUPT_THRESHOLD, EMISSIONS_FLAG_BORROW_ACTIVE, EMISSIONS_FLAG_LENDING_ACTIVE, EMPTY_BALANCE_THRESHOLD, EXP_10_I80F48, MIN_EMISSIONS_START_TIME, SECONDS_PER_YEAR, ZERO_AMOUNT_THRESHOLD }, debug, math_error, prelude::{MarginfiError, MarginfiResult}, @@ -753,7 +751,10 @@ assert_struct_align!(Balance, 8); pub struct Balance { pub active: bool, pub bank_pk: Pubkey, - pub _pad0: [u8; 7], + /// Inherited from the bank when the position is first created and CANNOT BE CHANGED after that. + /// Note that all balances created before the addition of this feature use `ASSET_TAG_DEFAULT` + pub bank_asset_tag: u8, + pub _pad0: [u8; 6], pub asset_shares: WrappedI80F48, pub liability_shares: WrappedI80F48, pub emissions_outstanding: WrappedI80F48, @@ -825,7 +826,8 @@ impl Balance { Balance { active: false, bank_pk: Pubkey::default(), - _pad0: [0; 7], + bank_asset_tag: ASSET_TAG_DEFAULT, + _pad0: [0; 6], asset_shares: WrappedI80F48::from(I80F48::ZERO), liability_shares: WrappedI80F48::from(I80F48::ZERO), emissions_outstanding: WrappedI80F48::from(I80F48::ZERO), @@ -885,7 +887,8 @@ impl<'a> BankAccountWrapper<'a> { lending_account.balances[empty_index] = Balance { active: true, bank_pk: *bank_pk, - _pad0: [0; 7], + bank_asset_tag: bank.config.asset_tag, + _pad0: [0; 6], asset_shares: I80F48::ZERO.into(), liability_shares: I80F48::ZERO.into(), emissions_outstanding: I80F48::ZERO.into(), @@ -1415,7 +1418,8 @@ mod test { balances: [Balance { active: true, bank_pk: bank_pk.into(), - _pad0: [0; 7], + bank_asset_tag: ASSET_TAG_DEFAULT, + _pad0: [0; 6], asset_shares: WrappedI80F48::default(), liability_shares: WrappedI80F48::default(), emissions_outstanding: WrappedI80F48::default(), diff --git a/programs/marginfi/src/state/marginfi_group.rs b/programs/marginfi/src/state/marginfi_group.rs index 5dbba8015..e291d9d39 100644 --- a/programs/marginfi/src/state/marginfi_group.rs +++ b/programs/marginfi/src/state/marginfi_group.rs @@ -1031,7 +1031,7 @@ impl From for BankConfig { _pad0: [0; 6], borrow_limit: config.borrow_limit, risk_tier: config.risk_tier, - asset_tag: ASSET_TAG_DEFAULT, + asset_tag: config.asset_tag, _pad1: [0; 6], total_asset_value_init_limit: config.total_asset_value_init_limit, oracle_max_age: config.oracle_max_age, @@ -1054,7 +1054,7 @@ impl From for BankConfigCompact { oracle_key: config.oracle_keys[0], borrow_limit: config.borrow_limit, risk_tier: config.risk_tier, - asset_tag: ASSET_TAG_DEFAULT, + asset_tag: config.asset_tag, _pad0: [0; 6], total_asset_value_init_limit: config.total_asset_value_init_limit, oracle_max_age: config.oracle_max_age, diff --git a/programs/marginfi/src/utils.rs b/programs/marginfi/src/utils.rs index fc79d68b7..0cccacfe6 100644 --- a/programs/marginfi/src/utils.rs +++ b/programs/marginfi/src/utils.rs @@ -1,6 +1,10 @@ use crate::{ bank_authority_seed, bank_seed, - state::marginfi_group::{Bank, BankVaultType}, + constants::{ASSET_TAG_DEFAULT, ASSET_TAG_SOL, ASSET_TAG_STAKED}, + state::{ + marginfi_account::MarginfiAccount, + marginfi_group::{Bank, BankVaultType}, + }, MarginfiError, MarginfiResult, }; use anchor_lang::prelude::*; @@ -192,3 +196,33 @@ pub fn hex_to_bytes(hex: &str) -> Vec { }) .collect() } + +/// Validate that after a deposit to Bank, the users's account contains either all Default/SOL +/// balances, or all Staked/Sol balances. Default and Staked assets cannot mix. +pub fn validate_asset_tags(bank: &Bank, marginfi_account: &MarginfiAccount) -> MarginfiResult { + let mut has_default_asset = false; + let mut has_staked_asset = false; + + for balance in marginfi_account.lending_account.balances.iter() { + if balance.active { + match balance.bank_asset_tag { + ASSET_TAG_DEFAULT => has_default_asset = true, + ASSET_TAG_SOL => { /* Do nothing, SOL can mix with any asset type */ } + ASSET_TAG_STAKED => has_staked_asset = true, + _ => panic!("unsupported asset tag"), + } + } + } + + // 1. Regular assets (DEFAULT) cannot mix with Staked assets + if bank.config.asset_tag == ASSET_TAG_DEFAULT && has_staked_asset { + return err!(MarginfiError::AssetTagMismatch); + } + + // 2. Staked SOL cannot mix with Regular asset (DEFAULT) + if bank.config.asset_tag == ASSET_TAG_STAKED && has_default_asset { + return err!(MarginfiError::AssetTagMismatch); + } + + Ok(()) +} diff --git a/programs/marginfi/tests/misc/regression.rs b/programs/marginfi/tests/misc/regression.rs index 2773f0208..40e319e4b 100644 --- a/programs/marginfi/tests/misc/regression.rs +++ b/programs/marginfi/tests/misc/regression.rs @@ -53,7 +53,8 @@ async fn account_field_values_reg() -> anyhow::Result<()> { balance_1.bank_pk, pubkey!("2s37akK2eyBbp8DZgCm7RtsaEz8eJP3Nxd4urLHQv7yB") ); - assert_eq!(balance_1._pad0, [0; 7]); + assert_eq!(balance_1.bank_asset_tag, ASSET_TAG_DEFAULT); + assert_eq!(balance_1._pad0, [0; 6]); assert_eq!( I80F48::from(balance_1.asset_shares), I80F48::from_str("1650216221.466876226897366").unwrap() @@ -78,7 +79,8 @@ async fn account_field_values_reg() -> anyhow::Result<()> { balance_2.bank_pk, pubkey!("CCKtUs6Cgwo4aaQUmBPmyoApH2gUDErxNZCAntD6LYGh") ); - assert_eq!(balance_2._pad0, [0; 7]); + assert_eq!(balance_2.bank_asset_tag, ASSET_TAG_DEFAULT); + assert_eq!(balance_2._pad0, [0; 6]); assert_eq!( I80F48::from(balance_2.asset_shares), I80F48::from_str("0").unwrap() @@ -128,7 +130,8 @@ async fn account_field_values_reg() -> anyhow::Result<()> { balance_1.bank_pk, pubkey!("6hS9i46WyTq1KXcoa2Chas2Txh9TJAVr6n1t3tnrE23K") ); - assert_eq!(balance_1._pad0, [0; 7]); + assert_eq!(balance_1.bank_asset_tag, ASSET_TAG_DEFAULT); + assert_eq!(balance_1._pad0, [0; 6]); assert_eq!( I80F48::from(balance_1.asset_shares), I80F48::from_str("470.952530958931234").unwrap() @@ -153,7 +156,8 @@ async fn account_field_values_reg() -> anyhow::Result<()> { balance_2.bank_pk, pubkey!("11111111111111111111111111111111") ); - assert_eq!(balance_2._pad0, [0; 7]); + assert_eq!(balance_2.bank_asset_tag, ASSET_TAG_DEFAULT); + assert_eq!(balance_2._pad0, [0; 6]); assert_eq!( I80F48::from(balance_2.asset_shares), I80F48::from_str("0").unwrap() @@ -203,7 +207,8 @@ async fn account_field_values_reg() -> anyhow::Result<()> { balance_1.bank_pk, pubkey!("11111111111111111111111111111111") ); - assert_eq!(balance_1._pad0, [0; 7]); + assert_eq!(balance_1.bank_asset_tag, ASSET_TAG_DEFAULT); + assert_eq!(balance_1._pad0, [0; 6]); assert_eq!( I80F48::from(balance_1.asset_shares), I80F48::from_str("0").unwrap() diff --git a/tests/rootHooks.ts b/tests/rootHooks.ts index 88f584f9e..093c340af 100644 --- a/tests/rootHooks.ts +++ b/tests/rootHooks.ts @@ -60,6 +60,8 @@ export const marginfiGroup = Keypair.generate(); export const bankKeypairUsdc = Keypair.generate(); /** Bank for token A */ export const bankKeypairA = Keypair.generate(); +/** Bank for "WSOL", which is treated the same as SOL */ +export const bankKeypairSol = Keypair.generate(); export let bankrunContext: ProgramTestContext; export let bankRunProvider: BankrunProvider; @@ -74,24 +76,31 @@ export const mochaHooks = { const provider = AnchorProvider.local(); const wallet = provider.wallet as Wallet; + copyKeys.push(wallet.publicKey); + if (verbose) { console.log("Global Ecosystem Information "); echoEcosystemInfo(ecosystem, { skipA: false, skipB: false, skipUsdc: false, - skipWsol: true, + skipWsol: false, }); console.log(""); } + const { ixes: wsolIxes, mint: wsolMint } = await createSimpleMint( + provider.publicKey, + provider.connection, + ecosystem.wsolDecimals, + ecosystem.wsolMint + ); const { ixes: usdcIxes, mint: usdcMint } = await createSimpleMint( provider.publicKey, provider.connection, ecosystem.usdcDecimals, ecosystem.usdcMint ); - const { ixes: aIxes, mint: aMint } = await createSimpleMint( provider.publicKey, provider.connection, @@ -105,18 +114,24 @@ export const mochaHooks = { ecosystem.tokenBMint ); const tx = new Transaction(); + tx.add(...wsolIxes); tx.add(...usdcIxes); tx.add(...aIxes); tx.add(...bIxes); - await provider.sendAndConfirm(tx, [usdcMint, aMint, bMint]); - copyKeys.push(usdcMint.publicKey, aMint.publicKey, bMint.publicKey); + await provider.sendAndConfirm(tx, [wsolMint, usdcMint, aMint, bMint]); + copyKeys.push( + wsolMint.publicKey, + usdcMint.publicKey, + aMint.publicKey, + bMint.publicKey + ); const setupUserOptions: SetupTestUserOptions = { marginProgram: mrgnProgram, forceWallet: undefined, // If mints are created, typically create the ATA too, otherwise pass undefined... - wsolMint: undefined, + wsolMint: ecosystem.wsolMint.publicKey, tokenAMint: ecosystem.tokenAMint.publicKey, tokenBMint: ecosystem.tokenBMint.publicKey, usdcMint: ecosystem.usdcMint.publicKey, @@ -224,7 +239,7 @@ const addUser = (user: MockUser) => { // copyKeys.push(user.tokenBAccount); copyKeys.push(user.usdcAccount); copyKeys.push(user.wallet.publicKey); - // copyKeys.push(user.wsolAccount); + copyKeys.push(user.wsolAccount); }; /** @@ -279,6 +294,7 @@ export const createValidator = async ( splMint: PublicKey.default, splAuthority: PublicKey.default, splStake: PublicKey.default, + bank: PublicKey.default, }; return validator; diff --git a/tests/s01_usersStake.spec.ts b/tests/s01_usersStake.spec.ts index 1542386d1..ae43f4678 100644 --- a/tests/s01_usersStake.spec.ts +++ b/tests/s01_usersStake.spec.ts @@ -38,10 +38,11 @@ import { getBankrunBlockhash, } from "./utils/spl-staking-utils"; import { assert } from "chai"; +import { LST_ATA, STAKE_ACC } from "./utils/mocks"; describe("User stakes some native and creates an account", () => { /** Users's validator 0 stake account */ - let stakeAccount: PublicKey; + let user0StakeAccount: PublicKey; const stake = 10; it("(user 0) Create user stake account and stake to validator", async () => { @@ -53,10 +54,10 @@ describe("User stakes some native and creates an account", () => { createTx.recentBlockhash = await getBankrunBlockhash(bankrunContext); createTx.sign(users[0].wallet, stakeAccountKeypair); await banksClient.processTransaction(createTx); - stakeAccount = stakeAccountKeypair.publicKey; + user0StakeAccount = stakeAccountKeypair.publicKey; if (verbose) { - console.log("Create stake account: " + stakeAccount); + console.log("Create stake account: " + user0StakeAccount); console.log( " Stake: " + stake + @@ -65,11 +66,11 @@ describe("User stakes some native and creates an account", () => { " in native)" ); } - users[0].accounts.set("v0_stakeAcc", stakeAccount); + users[0].accounts.set("v0_stakeAcc", user0StakeAccount); let delegateTx = delegateStake( users[0], - stakeAccount, + user0StakeAccount, validators[0].voteAccount ); delegateTx.recentBlockhash = await getBankrunBlockhash(bankrunContext); @@ -82,7 +83,7 @@ describe("User stakes some native and creates an account", () => { let { epoch, slot } = await getEpochAndSlot(banksClient); const stakeAccountInfo = await bankRunProvider.connection.getAccountInfo( - stakeAccount + user0StakeAccount ); const stakeAccBefore = getStakeAccount(stakeAccountInfo.data); const meta = stakeAccBefore.meta; @@ -99,7 +100,7 @@ describe("User stakes some native and creates an account", () => { const stakeStatusBefore = await getStakeActivation( bankRunProvider.connection, - stakeAccount, + user0StakeAccount, epoch ); if (verbose) { @@ -115,7 +116,26 @@ describe("User stakes some native and creates an account", () => { } }); - // User delegates to stake pool (this works fine) + it("(user 1) Stakes and delegates too", async () => { + const user = users[1]; + let { createTx, stakeAccountKeypair } = createStakeAccount( + user, + stake * LAMPORTS_PER_SOL + ); + createTx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + createTx.sign(user.wallet, stakeAccountKeypair); + await banksClient.processTransaction(createTx); + user.accounts.set(STAKE_ACC, stakeAccountKeypair.publicKey); + + let delegateTx = delegateStake( + user, + stakeAccountKeypair.publicKey, + validators[0].voteAccount + ); + delegateTx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + delegateTx.sign(user.wallet); + await banksClient.processTransaction(delegateTx); + }); it("Advance the epoch", async () => { bankrunContext.warpToEpoch(1n); @@ -131,7 +151,7 @@ describe("User stakes some native and creates an account", () => { const stakeStatusAfter = await getStakeActivation( bankRunProvider.connection, - stakeAccount, + user0StakeAccount, epochAfterWarp ); if (verbose) { @@ -176,13 +196,13 @@ describe("User stakes some native and creates an account", () => { }); it("(user 0) Deposits stake to the LST pool", async () => { - const userStakeAccount = users[0].accounts.get("v0_stakeAcc"); + const userStakeAccount = users[0].accounts.get(STAKE_ACC); // Note: use `findPoolMintAddress(SINGLE_POOL_PROGRAM_ID, splPool);` if mint is not known. const lstAta = getAssociatedTokenAddressSync( validators[0].splMint, users[0].wallet.publicKey ); - users[0].accounts.set("v0_lstAta", lstAta); + users[0].accounts.set(LST_ATA, lstAta); // Note: user stake account exists before, but is closed after // Here we note the balance of the stake account prior @@ -246,6 +266,7 @@ describe("User stakes some native and creates an account", () => { console.log("lst after: " + lstAfter.toLocaleString()); } // LST tokens are issued 1:1 with stake because there has been zero appreciation + // Also note that LST tokens use the same decimals. assert.equal(lstAfter, delegationBefore); const splStakePool = getStakeAccount(splStakePoolInfo.data); @@ -261,4 +282,26 @@ describe("User stakes some native and creates an account", () => { delegationBefore ); }); + + it("(user 1) deposits to the stake pool too", async () => { + const user = users[1]; + let tx = new Transaction(); + const ixes = await depositToSinglePoolIxes( + bankRunProvider.connection, + user.wallet.publicKey, + validators[0].splPool, + user.accounts.get(STAKE_ACC), + verbose + ); + tx.add(...ixes); + tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + tx.sign(user.wallet); + await banksClient.processTransaction(tx); + + const lstAta = getAssociatedTokenAddressSync( + validators[0].splMint, + user.wallet.publicKey + ); + user.accounts.set(LST_ATA, lstAta); + }); }); diff --git a/tests/s02_addBank.spec.ts b/tests/s02_addBank.spec.ts index 5d5175c48..bc8778c58 100644 --- a/tests/s02_addBank.spec.ts +++ b/tests/s02_addBank.spec.ts @@ -1,38 +1,30 @@ import { BN, Program, workspace } from "@coral-xyz/anchor"; -import { PublicKey, Transaction } from "@solana/web3.js"; +import { Keypair, Transaction } from "@solana/web3.js"; import { addBank, groupInitialize } from "./utils/group-instructions"; import { Marginfi } from "../target/types/marginfi"; import { - bankKeypairA, + bankKeypairSol, bankKeypairUsdc, bankrunContext, + bankrunProgram, banksClient, ecosystem, groupAdmin, marginfiGroup, oracles, - printBuffers, - users, + validators, verbose, } from "./rootHooks"; import { - assertBNEqual, - assertI80F48Approx, - assertI80F48Equal, - assertKeyDefault, assertKeysEqual, } from "./utils/genericTests"; -import { ASSET_TAG_DEFAULT, defaultBankConfig } from "./utils/types"; import { - deriveLiquidityVaultAuthority, - deriveLiquidityVault, - deriveInsuranceVaultAuthority, - deriveInsuranceVault, - deriveFeeVaultAuthority, - deriveFeeVault, -} from "./utils/pdas"; + ASSET_TAG_DEFAULT, + ASSET_TAG_SOL, + ASSET_TAG_STAKED, + defaultBankConfig, +} from "./utils/types"; import { assert } from "chai"; -import { printBufferGroups } from "./utils/tools"; import { getBankrunBlockhash } from "./utils/spl-staking-utils"; describe("Init group and add banks with asset category flags", () => { @@ -51,7 +43,7 @@ describe("Init group and add banks with asset category flags", () => { tx.sign(groupAdmin.wallet, marginfiGroup); await banksClient.processTransaction(tx); - let group = await program.account.marginfiGroup.fetch( + let group = await bankrunProgram.account.marginfiGroup.fetch( marginfiGroup.publicKey ); assertKeysEqual(group.admin, groupAdmin.wallet.publicKey); @@ -64,10 +56,8 @@ describe("Init group and add banks with asset category flags", () => { it("(admin) Add bank (USDC) - is neither SOL nor staked LST", async () => { let setConfig = defaultBankConfig(oracles.usdcOracle.publicKey); let bankKey = bankKeypairUsdc.publicKey; - const now = Date.now() / 1000; let tx = new Transaction(); - tx.add( await addBank(program, { marginfiGroup: marginfiGroup.publicKey, @@ -86,37 +76,68 @@ describe("Init group and add banks with asset category flags", () => { console.log("*init USDC bank " + bankKey); } - let bankData = ( - await program.provider.connection.getAccountInfo(bankKey) - ).data.subarray(8); - if (printBuffers) { - printBufferGroups(bankData, 16, 896); + const bank = await bankrunProgram.account.bank.fetch(bankKey); + assert.equal(bank.config.assetTag, ASSET_TAG_DEFAULT); + }); + + it("(admin) Add bank (SOL) - is tagged as SOL", async () => { + let setConfig = defaultBankConfig(oracles.wsolOracle.publicKey); + setConfig.assetTag = ASSET_TAG_SOL; + let bankKey = bankKeypairSol.publicKey; + + let tx = new Transaction(); + tx.add( + await addBank(program, { + marginfiGroup: marginfiGroup.publicKey, + admin: groupAdmin.wallet.publicKey, + feePayer: groupAdmin.wallet.publicKey, + bankMint: ecosystem.wsolMint.publicKey, + bank: bankKey, + config: setConfig, + }) + ); + tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + tx.sign(groupAdmin.wallet, bankKeypairSol); + await banksClient.processTransaction(tx); + + if (verbose) { + console.log("*init SOL bank " + bankKey); + } + + const bank = await bankrunProgram.account.bank.fetch(bankKey); + assert.equal(bank.config.assetTag, ASSET_TAG_SOL); + }); + + it("(admin) Add bank (Staked SOL) - is tagged as Staked", async () => { + let setConfig = defaultBankConfig(oracles.wsolOracle.publicKey); + setConfig.assetTag = ASSET_TAG_STAKED; + // Staked assets aren't designed to be borrowed... + setConfig.borrowLimit = new BN(0); + let bankKeypair = Keypair.generate(); + validators[0].bank = bankKeypair.publicKey; + + let tx = new Transaction(); + tx.add( + await addBank(program, { + marginfiGroup: marginfiGroup.publicKey, + admin: groupAdmin.wallet.publicKey, + feePayer: groupAdmin.wallet.publicKey, + bankMint: validators[0].splMint, + bank: validators[0].bank, + config: setConfig, + }) + ); + tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + tx.sign(groupAdmin.wallet, bankKeypair); + await banksClient.processTransaction(tx); + + if (verbose) { + console.log("*init LST bank " + validators[0].bank + " (validator 0)"); } - const bank = await program.account.bank.fetch(bankKey); - assert.equal(bank.config.assetTag, ASSET_TAG_DEFAULT); + const bank = await bankrunProgram.account.bank.fetch(validators[0].bank); + assert.equal(bank.config.assetTag, ASSET_TAG_STAKED); }); - // it("(admin) Add bank (token A) - happy path", async () => { - // let config = defaultBankConfig(oracles.tokenAOracle.publicKey); - // let bankKey = bankKeypairA.publicKey; - - // await groupAdmin.userMarginProgram!.provider.sendAndConfirm!( - // new Transaction().add( - // await addBank(program, { - // marginfiGroup: marginfiGroup.publicKey, - // admin: groupAdmin.wallet.publicKey, - // feePayer: groupAdmin.wallet.publicKey, - // bankMint: ecosystem.tokenAMint.publicKey, - // bank: bankKey, - // config: config, - // }) - // ), - // [bankKeypairA] - // ); - - // if (verbose) { - // console.log("*init token A bank " + bankKey); - // } - // }); + // TODO add an LST-based pool without permission }); diff --git a/tests/s03_deposit.spec.ts b/tests/s03_deposit.spec.ts new file mode 100644 index 000000000..9d5ee0259 --- /dev/null +++ b/tests/s03_deposit.spec.ts @@ -0,0 +1,236 @@ +import { + AnchorProvider, + BN, + getProvider, + Program, + Wallet, + workspace, +} from "@coral-xyz/anchor"; +import { Keypair, Transaction } from "@solana/web3.js"; +import { Marginfi } from "../target/types/marginfi"; +import { + bankKeypairA, + bankKeypairSol, + bankKeypairUsdc, + bankrunContext, + bankrunProgram, + banksClient, + ecosystem, + groupAdmin, + marginfiGroup, + numUsers, + users, + validators, + verbose, +} from "./rootHooks"; +import { + assertBankrunTxFailed, + assertBNApproximately, + assertI80F48Approx, + assertI80F48Equal, + assertKeysEqual, + getTokenBalance, +} from "./utils/genericTests"; +import { assert } from "chai"; +import { accountInit, depositIx } from "./utils/user-instructions"; +import { LST_ATA, USER_ACCOUNT } from "./utils/mocks"; +import { createMintToInstruction } from "@solana/spl-token"; +import { deriveLiquidityVault } from "./utils/pdas"; +import { getBankrunBlockhash } from "./utils/spl-staking-utils"; +import { BanksTransactionResultWithMeta } from "solana-bankrun"; + +describe("Deposit funds (included staked assets)", () => { + const program = workspace.Marginfi as Program; + const provider = getProvider() as AnchorProvider; + const wallet = provider.wallet as Wallet; + + it("(Fund user 0 and user 1 USDC/WSOL token accounts", async () => { + let tx = new Transaction(); + for (let i = 0; i < users.length; i++) { + // Note: WSOL is really just an spl token in this implementation, we don't simulate the + // exchange of SOL for WSOL, but that doesn't really matter. + tx.add( + createMintToInstruction( + ecosystem.wsolMint.publicKey, + users[i].wsolAccount, + wallet.publicKey, + 100 * 10 ** ecosystem.wsolDecimals + ) + ); + tx.add( + createMintToInstruction( + ecosystem.usdcMint.publicKey, + users[i].usdcAccount, + wallet.publicKey, + 10000 * 10 ** ecosystem.usdcDecimals + ) + ); + } + tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + tx.sign(wallet.payer); + await banksClient.processTransaction(tx); + }); + + it("Initialize user accounts", async () => { + for (let i = 0; i < users.length; i++) { + const userAccKeypair = Keypair.generate(); + const userAccount = userAccKeypair.publicKey; + users[i].accounts.set(USER_ACCOUNT, userAccount); + + let user1Tx: Transaction = new Transaction(); + user1Tx.add( + await accountInit(program, { + marginfiGroup: marginfiGroup.publicKey, + marginfiAccount: userAccount, + authority: users[i].wallet.publicKey, + feePayer: users[i].wallet.publicKey, + }) + ); + user1Tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + user1Tx.sign(users[i].wallet, userAccKeypair); + await banksClient.processTransaction(user1Tx); + } + }); + + it("(user 0) deposit USDC to bank - happy path", async () => { + const user = users[0]; + const userAccount = user.accounts.get(USER_ACCOUNT); + + let tx = new Transaction().add( + await depositIx(program, { + marginfiGroup: marginfiGroup.publicKey, + marginfiAccount: userAccount, + authority: user.wallet.publicKey, + bank: bankKeypairUsdc.publicKey, + tokenAccount: user.usdcAccount, + amount: new BN(10 * 10 ** ecosystem.usdcDecimals), + }) + ); + + tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + tx.sign(user.wallet); + await banksClient.tryProcessTransaction(tx); + + // Verify the deposit worked and the account exists + const userAcc = await bankrunProgram.account.marginfiAccount.fetch( + userAccount + ); + const balances = userAcc.lendingAccount.balances; + assert.equal(balances[0].active, true); + assertKeysEqual(balances[0].bankPk, bankKeypairUsdc.publicKey); + }); + + it("(user 0) cannot deposit to staked bank if regular deposits exists - should fail", async () => { + const user = users[0]; + const userAccount = user.accounts.get(USER_ACCOUNT); + const userLstAta = user.accounts.get(LST_ATA); + + let tx = new Transaction().add( + await depositIx(program, { + marginfiGroup: marginfiGroup.publicKey, + marginfiAccount: userAccount, + authority: user.wallet.publicKey, + bank: validators[0].bank, + tokenAccount: userLstAta, + amount: new BN(1 * 10 ** ecosystem.wsolDecimals), + }) + ); + + tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + tx.sign(user.wallet); + let result = await banksClient.tryProcessTransaction(tx); + assertBankrunTxFailed(result, "0x179f"); + + // Verify the deposit failed and the entry does not exist + const userAcc = await bankrunProgram.account.marginfiAccount.fetch( + userAccount + ); + const balances = userAcc.lendingAccount.balances; + assert.equal(balances[1].active, false); + }); + + it("(user 1) deposits SOL to SOL bank - happy path", async () => { + const user = users[1]; + const userAccount = user.accounts.get(USER_ACCOUNT); + + let tx = new Transaction().add( + await depositIx(program, { + marginfiGroup: marginfiGroup.publicKey, + marginfiAccount: userAccount, + authority: user.wallet.publicKey, + bank: bankKeypairSol.publicKey, + tokenAccount: user.wsolAccount, + amount: new BN(2 * 10 ** ecosystem.wsolDecimals), + }) + ); + + tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + tx.sign(user.wallet); + await banksClient.tryProcessTransaction(tx); + + // Verify the deposit worked and the account exists + const userAcc = await bankrunProgram.account.marginfiAccount.fetch( + userAccount + ); + const balances = userAcc.lendingAccount.balances; + assert.equal(balances[0].active, true); + assertKeysEqual(balances[0].bankPk, bankKeypairSol.publicKey); + }); + + it("(user 1) deposits to staked bank - should succeed (SOL co-mingle is allowed)", async () => { + const user = users[1]; + const userAccount = user.accounts.get(USER_ACCOUNT); + const userLstAta = user.accounts.get(LST_ATA); + + let tx = new Transaction().add( + await depositIx(program, { + marginfiGroup: marginfiGroup.publicKey, + marginfiAccount: userAccount, + authority: user.wallet.publicKey, + bank: validators[0].bank, + tokenAccount: userLstAta, + amount: new BN(1 * 10 ** ecosystem.wsolDecimals), + }) + ); + + tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + tx.sign(user.wallet); + await banksClient.tryProcessTransaction(tx); + + // Verify the deposit worked and the entry exists + const userAcc = await bankrunProgram.account.marginfiAccount.fetch( + userAccount + ); + const balances = userAcc.lendingAccount.balances; + assert.equal(balances[1].active, true); + assertKeysEqual(balances[1].bankPk, validators[0].bank); + }); + + it("(user 1) cannot deposit to regular banks (USDC) with staked assets - should fail", async () => { + const user = users[1]; + const userAccount = user.accounts.get(USER_ACCOUNT); + + let tx = new Transaction().add( + await depositIx(program, { + marginfiGroup: marginfiGroup.publicKey, + marginfiAccount: userAccount, + authority: user.wallet.publicKey, + bank: bankKeypairUsdc.publicKey, + tokenAccount: user.usdcAccount, + amount: new BN(1 * 10 ** ecosystem.usdcDecimals), + }) + ); + + tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + tx.sign(user.wallet); + let result = await banksClient.tryProcessTransaction(tx); + assertBankrunTxFailed(result, "0x179f"); + + // Verify the deposit failed and the entry does not exist + const userAcc = await bankrunProgram.account.marginfiAccount.fetch( + userAccount + ); + const balances = userAcc.lendingAccount.balances; + assert.equal(balances[2].active, false); + }); +}); diff --git a/tests/s04_borrow.spec.ts b/tests/s04_borrow.spec.ts new file mode 100644 index 000000000..682cc137d --- /dev/null +++ b/tests/s04_borrow.spec.ts @@ -0,0 +1,121 @@ +import { + AnchorProvider, + BN, + getProvider, + Program, + Wallet, + workspace, +} from "@coral-xyz/anchor"; +import { Keypair, Transaction } from "@solana/web3.js"; +import { Marginfi } from "../target/types/marginfi"; +import { + bankKeypairA, + bankKeypairSol, + bankKeypairUsdc, + bankrunContext, + bankrunProgram, + bankRunProvider, + banksClient, + ecosystem, + groupAdmin, + marginfiGroup, + numUsers, + oracles, + users, + validators, + verbose, +} from "./rootHooks"; +import { + assertBankrunTxFailed, + assertBNApproximately, + assertI80F48Approx, + assertI80F48Equal, + assertKeysEqual, + getTokenBalance, +} from "./utils/genericTests"; +import { assert } from "chai"; +import { accountInit, borrowIx, depositIx } from "./utils/user-instructions"; +import { USER_ACCOUNT } from "./utils/mocks"; +import { createMintToInstruction } from "@solana/spl-token"; +import { deriveLiquidityVault } from "./utils/pdas"; +import { getBankrunBlockhash } from "./utils/spl-staking-utils"; +import { BanksTransactionResultWithMeta } from "solana-bankrun"; + +describe("Deposit funds (included staked assets)", () => { + const program = workspace.Marginfi as Program; + + // User 0 has a USDC deposit position + // User 1 has a SOL [0] and validator 0 Staked [1] deposit position + + it("(user 0) borrows SOL against their USDC position - succeeds (SOL/regular comingle is allowed)", async () => { + const user = users[0]; + const userAccount = user.accounts.get(USER_ACCOUNT); + + let tx = new Transaction().add( + await borrowIx(program, { + marginfiGroup: marginfiGroup.publicKey, + marginfiAccount: userAccount, + authority: user.wallet.publicKey, + bank: bankKeypairSol.publicKey, + tokenAccount: user.wsolAccount, + remaining: [ + bankKeypairUsdc.publicKey, + oracles.usdcOracle.publicKey, + bankKeypairSol.publicKey, + oracles.wsolOracle.publicKey, + ], + amount: new BN(0.01 * 10 ** ecosystem.wsolDecimals), + }) + ); + tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + tx.sign(user.wallet); + await banksClient.processTransaction(tx); + + const userAcc = await bankrunProgram.account.marginfiAccount.fetch( + userAccount + ); + const balances = userAcc.lendingAccount.balances; + assert.equal(balances[1].active, true); + assertKeysEqual(balances[1].bankPk, bankKeypairSol.publicKey); + }); + + // Note: Borrowing STAKED assets is generally forbidden (their borrow cap is set to 0) + // If we ever change this, add a test here to validate user 0 cannot borrow staked assets + + it("(user 1) tries to borrow USDC - should fail (Regular assets cannot comingle with Staked)", async () => { + const user = users[1]; + const userAccount = user.accounts.get(USER_ACCOUNT); + + let tx = new Transaction().add( + await borrowIx(program, { + marginfiGroup: marginfiGroup.publicKey, + marginfiAccount: userAccount, + authority: user.wallet.publicKey, + bank: bankKeypairUsdc.publicKey, + tokenAccount: user.usdcAccount, + remaining: [ + bankKeypairSol.publicKey, + oracles.wsolOracle.publicKey, + validators[0].bank, + oracles.wsolOracle.publicKey, // Note the Staked bank uses wsol oracle too + bankKeypairUsdc.publicKey, + oracles.usdcOracle.publicKey, + ], + amount: new BN(0.1 * 10 ** ecosystem.usdcDecimals), + }) + ); + tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + tx.sign(user.wallet); + let result = await banksClient.tryProcessTransaction(tx); + assertBankrunTxFailed(result, "0x179f"); + + // Verify the deposit worked and the entry does not exist + const userAcc = await bankrunProgram.account.marginfiAccount.fetch( + userAccount + ); + const balances = userAcc.lendingAccount.balances; + assert.equal(balances[2].active, false); + }); + + // TODO withdraw user 1's SOL collateral and verify they can borrow SOL +}); diff --git a/tests/utils/genericTests.ts b/tests/utils/genericTests.ts index 730167158..d1b52f588 100644 --- a/tests/utils/genericTests.ts +++ b/tests/utils/genericTests.ts @@ -7,6 +7,7 @@ import { BankrunProvider } from "anchor-bankrun"; import BigNumber from "bignumber.js"; import BN from "bn.js"; import { assert } from "chai"; +import { BanksTransactionResultWithMeta } from "solana-bankrun"; /** * Shorthand for `assert.equal(a.toString(), b.toString())` @@ -175,3 +176,19 @@ export const waitUntil = async ( const toWait = Math.ceil(time - now) * 1000; await new Promise((r) => setTimeout(r, toWait)); }; + +/** + * Assert a bankrun Tx executed with `tryProcessTransaction` failed with the expected error code. + * Throws an error if the tx succeeded or a different error was found. + * @param result + * @param expectedErrorCode - In hex, as you see in Anchor logs, e.g. for error 6047 pass `0x179f` + */ +export const assertBankrunTxFailed = ( + result: BanksTransactionResultWithMeta, + expectedErrorCode: string +) => { + assert(result.meta.logMessages.length > 0); + assert(result.result, "TX succeeded when it should have failed"); + const lastLog = result.meta.logMessages.pop(); + assert(lastLog.includes(expectedErrorCode), "Actual error: " + lastLog); +}; diff --git a/tests/utils/mocks.ts b/tests/utils/mocks.ts index d74643806..fbe0ee984 100644 --- a/tests/utils/mocks.ts +++ b/tests/utils/mocks.ts @@ -103,6 +103,10 @@ export type MockUser = { /** in mockUser.accounts, key used to get/set the users's account for group 0 */ export const USER_ACCOUNT: string = "g0_acc"; +/** in mockUser.accounts, key used to get/set the users's LST ATA for validator 0 */ +export const LST_ATA = "v0_lstAta" +/** in mockUser.accounts, key used to get/set the users's LST stake account for validator 0 */ +export const STAKE_ACC = "v0_stakeAcc" /** * Options to skip various parts of mock user setup @@ -364,4 +368,6 @@ export type Validator = { splAuthority: PublicKey; /** spl pool's stake account */ splStake: PublicKey; + /** bank created for this validator's LST on the "main" group */ + bank: PublicKey; }; From 4489f7c302c42088e5297b95f2ff9b302b449d0c Mon Sep 17 00:00:00 2001 From: Jon Gurary Date: Fri, 20 Sep 2024 17:05:55 -0400 Subject: [PATCH 19/52] Updated readme --- programs/marginfi/fuzz/Cargo.lock | 128 ++++++++++++++++++++++++++---- programs/marginfi/fuzz/README.md | 18 +++++ 2 files changed, 132 insertions(+), 14 deletions(-) diff --git a/programs/marginfi/fuzz/Cargo.lock b/programs/marginfi/fuzz/Cargo.lock index 8ff842fe6..7f6f2c814 100644 --- a/programs/marginfi/fuzz/Cargo.lock +++ b/programs/marginfi/fuzz/Cargo.lock @@ -404,7 +404,7 @@ dependencies = [ "anchor-lang 0.29.0", "solana-program", "spl-associated-token-account 2.3.0", - "spl-token", + "spl-token 4.0.0", "spl-token-2022 0.9.0", ] @@ -416,7 +416,7 @@ dependencies = [ "anchor-lang 0.30.1", "spl-associated-token-account 3.0.2", "spl-pod 0.2.2", - "spl-token", + "spl-token 4.0.0", "spl-token-2022 3.0.2", "spl-token-group-interface 0.2.3", "spl-token-metadata-interface 0.3.3", @@ -2744,6 +2744,7 @@ dependencies = [ name = "marginfi" version = "0.1.0" dependencies = [ + "anchor-lang 0.29.0", "anchor-lang 0.30.1", "anchor-spl 0.30.1", "borsh 0.10.3", @@ -2760,6 +2761,7 @@ dependencies = [ "spl-tlv-account-resolution 0.6.3", "spl-transfer-hook-interface 0.6.3", "static_assertions", + "switchboard-on-demand", "switchboard-solana", "type-layout", ] @@ -2791,7 +2793,7 @@ dependencies = [ "solana-program", "solana-program-test", "solana-sdk", - "spl-token", + "spl-token 4.0.0", "strum 0.26.3", ] @@ -2965,10 +2967,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8536030f9fea7127f841b45bb6243b27255787fb4eb83958aa1ef9d2fdc0c36" dependencies = [ "num-bigint 0.2.6", - "num-complex", + "num-complex 0.2.4", "num-integer", "num-iter", - "num-rational", + "num-rational 0.2.4", + "num-traits", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint 0.4.6", + "num-complex 0.4.6", + "num-integer", + "num-iter", + "num-rational 0.4.2", "num-traits", ] @@ -3003,6 +3019,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -3063,6 +3088,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint 0.4.6", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3082,6 +3118,15 @@ dependencies = [ "libc", ] +[[package]] +name = "num_enum" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f646caf906c20226733ed5b1374287eb97e3c2a5c227ce668c1f2ce20ae57c9" +dependencies = [ + "num_enum_derive 0.5.11", +] + [[package]] name = "num_enum" version = "0.6.1" @@ -3100,6 +3145,18 @@ dependencies = [ "num_enum_derive 0.7.2", ] +[[package]] +name = "num_enum_derive" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcbff9bc912032c62bf65ef1d5aea88983b420f4f839db1e9b0c281a25c9c799" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "num_enum_derive" version = "0.6.1" @@ -3291,7 +3348,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd23b938276f14057220b707937bcb42fa76dda7560e57a2da30cb52d557937" dependencies = [ - "num", + "num 0.2.1", ] [[package]] @@ -4413,7 +4470,7 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "58267dd2fbaa6dceecba9e3e106d2d90a2b02497c0e8b01b8759beccf5113938" dependencies = [ - "num", + "num 0.2.1", ] [[package]] @@ -4449,7 +4506,7 @@ dependencies = [ "serde_json", "solana-config-program", "solana-sdk", - "spl-token", + "spl-token 4.0.0", "spl-token-2022 1.0.0", "spl-token-group-interface 0.1.0", "spl-token-metadata-interface 0.2.0", @@ -5430,7 +5487,7 @@ dependencies = [ "solana-sdk", "spl-associated-token-account 2.3.0", "spl-memo", - "spl-token", + "spl-token 4.0.0", "spl-token-2022 1.0.0", "thiserror", ] @@ -5602,7 +5659,7 @@ dependencies = [ "num-derive 0.4.2", "num-traits", "solana-program", - "spl-token", + "spl-token 4.0.0", "spl-token-2022 1.0.0", "thiserror", ] @@ -5618,7 +5675,7 @@ dependencies = [ "num-derive 0.4.2", "num-traits", "solana-program", - "spl-token", + "spl-token 4.0.0", "spl-token-2022 3.0.2", "thiserror", ] @@ -5820,6 +5877,21 @@ dependencies = [ "spl-type-length-value 0.4.3", ] +[[package]] +name = "spl-token" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e85e168a785e82564160dcb87b2a8e04cee9bfd1f4d488c729d53d6a4bd300d" +dependencies = [ + "arrayref", + "bytemuck", + "num-derive 0.3.3", + "num-traits", + "num_enum 0.5.11", + "solana-program", + "thiserror", +] + [[package]] name = "spl-token" version = "4.0.0" @@ -5850,7 +5922,7 @@ dependencies = [ "solana-zk-token-sdk", "spl-memo", "spl-pod 0.1.0", - "spl-token", + "spl-token 4.0.0", "spl-token-metadata-interface 0.2.0", "spl-transfer-hook-interface 0.3.0", "spl-type-length-value 0.3.0", @@ -5873,7 +5945,7 @@ dependencies = [ "solana-zk-token-sdk", "spl-memo", "spl-pod 0.1.0", - "spl-token", + "spl-token 4.0.0", "spl-token-group-interface 0.1.0", "spl-token-metadata-interface 0.2.0", "spl-transfer-hook-interface 0.4.1", @@ -5897,7 +5969,7 @@ dependencies = [ "solana-zk-token-sdk", "spl-memo", "spl-pod 0.2.2", - "spl-token", + "spl-token 4.0.0", "spl-token-group-interface 0.2.3", "spl-token-metadata-interface 0.3.3", "spl-transfer-hook-interface 0.6.3", @@ -6206,6 +6278,34 @@ dependencies = [ "sha3 0.10.8", ] +[[package]] +name = "switchboard-on-demand" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3852951c42f8876a443060b6882bda945f1621224236ead37959e80f5369cf81" +dependencies = [ + "arc-swap", + "async-trait", + "base64 0.21.7", + "bincode", + "borsh 0.10.3", + "bytemuck", + "futures", + "lazy_static", + "libsecp256k1 0.7.1", + "log", + "num 0.4.3", + "rust_decimal", + "serde", + "serde_json", + "sha2 0.10.8", + "solana-address-lookup-table-program", + "solana-program", + "spl-associated-token-account 2.3.0", + "spl-token 3.5.0", + "switchboard-common", +] + [[package]] name = "switchboard-solana" version = "0.29.109" diff --git a/programs/marginfi/fuzz/README.md b/programs/marginfi/fuzz/README.md index 4d2539b86..ebd3e99a3 100644 --- a/programs/marginfi/fuzz/README.md +++ b/programs/marginfi/fuzz/README.md @@ -28,3 +28,21 @@ Before the invoke we also copy to a local cache and revert the state if the inst ### Actions The framework uses the arbitrary library to generate a random sequence of actions that are then processed on the same state. + +### How to Run + +Run `python3 ./generate_corpus.py`. You may use python if you don't have python3 installed, or you may need to install python. + +Build with `cargo build`. + +If this fails, you probably need to update your Rust toolchain: + +`rustup install nightly-2024-06-05` + +And possibly: + +`rustup component add rust-src --toolchain nightly-2024-06-05-x86_64-unknown-linux-gnu` + +Run with `cargo +nightly-2024-06-05 fuzz run lend -Zbuild-std --strip-dead-code --no-cfg-fuzzing -- -max_total_time=300` + +To rerun some tests after a failure: `cargo +nightly-2024-06-05 fuzz run -Zbuild-std lend artifacts/lend/crash-ae5084b9433152babdaf7dcd75781eacd7ea55c7`, replacing the hash after crash- with the one you see in the terminal. From 4751da9b12becf4c408be08811d42ec167f54032 Mon Sep 17 00:00:00 2001 From: Jon Gurary Date: Fri, 20 Sep 2024 17:19:37 -0400 Subject: [PATCH 20/52] Updated anchor.toml to run the full test suite. All tests pass --- Anchor.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Anchor.toml b/Anchor.toml index 5f53fad36..c39bd0ee7 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -25,10 +25,10 @@ wallet = "~/.config/solana/id.json" # (remove RUST_LOG= to see bankRun logs) [scripts] -# test = "RUST_LOG= yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/*.spec.ts --exit --require tests/rootHooks.ts" +test = "RUST_LOG= yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/*.spec.ts --exit --require tests/rootHooks.ts" # Staking Collatizer only -test = "RUST_LOG= yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/s*.spec.ts --exit --require tests/rootHooks.ts" +# test = "RUST_LOG= yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/s*.spec.ts --exit --require tests/rootHooks.ts" [test] startup_wait = 5000 From 8249881a7d7117a9215871cc56d9808696626b7a Mon Sep 17 00:00:00 2001 From: Jon Gurary Date: Mon, 23 Sep 2024 23:56:24 -0400 Subject: [PATCH 21/52] Fix CI attempt 1 --- .github/workflows/test.yaml | 24 +++++++++---------- Anchor.toml | 2 +- .../marginfi_group/add_pool_permissionless.rs | 4 ++-- .../src/instructions/marginfi_group/mod.rs | 4 ++-- .../marginfi/src/state/marginfi_account.rs | 4 +++- 5 files changed, 20 insertions(+), 18 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index d7e1ce4d4..11f30fca2 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -139,21 +139,21 @@ jobs: - name: Build mocks program run: anchor build -p mocks - - name: Start Solana Test Validator - run: | - solana-test-validator --reset --limit-ledger-size 1000 \ + # - name: Start Solana Test Validator + # run: | + # solana-test-validator --reset --limit-ledger-size 1000 \ - - name: Wait for Validator to Start - run: sleep 60 + # - name: Wait for Validator to Start + # run: sleep 60 - - name: Deploy Liquidity Incentive Program - run: solana program deploy --program-id Lip1111111111111111111111111111111111111111 target/deploy/liquidity_incentive_program.so + # - name: Deploy Liquidity Incentive Program + # run: solana program deploy --program-id Lip1111111111111111111111111111111111111111 target/deploy/liquidity_incentive_program.so - - name: Deploy Marginfi Program - run: solana program deploy --program-id 2jGhuVUuy3umdzByFx8sNWUAaf5vaeuDm78RDPEnhrMr target/deploy/marginfi.so + # - name: Deploy Marginfi Program + # run: solana program deploy --program-id 2jGhuVUuy3umdzByFx8sNWUAaf5vaeuDm78RDPEnhrMr target/deploy/marginfi.so - - name: Deploy Mocks Program - run: solana program deploy --program-id 5XaaR94jBubdbrRrNW7DtRvZeWvLhSHkEGU3jHTEXV3C target/deploy/mocks.so + # - name: Deploy Mocks Program + # run: solana program deploy --program-id 5XaaR94jBubdbrRrNW7DtRvZeWvLhSHkEGU3jHTEXV3C target/deploy/mocks.so - name: Run tests - run: anchor test --skip-build --skip-local-validator + run: anchor test --skip-build diff --git a/Anchor.toml b/Anchor.toml index c39bd0ee7..1c6a71991 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -31,7 +31,7 @@ test = "RUST_LOG= yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/*.spec.t # test = "RUST_LOG= yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/s*.spec.ts --exit --require tests/rootHooks.ts" [test] -startup_wait = 5000 +startup_wait = 20000 shutdown_wait = 2000 upgradeable = false diff --git a/programs/marginfi/src/instructions/marginfi_group/add_pool_permissionless.rs b/programs/marginfi/src/instructions/marginfi_group/add_pool_permissionless.rs index 06cab04c8..2fa4dfab4 100644 --- a/programs/marginfi/src/instructions/marginfi_group/add_pool_permissionless.rs +++ b/programs/marginfi/src/instructions/marginfi_group/add_pool_permissionless.rs @@ -23,8 +23,8 @@ use crate::{ events::{GroupEventHeader, LendingPoolBankCreateEvent}, state::{ marginfi_group::{ - Bank, BankConfigCompact, BankOperationalState, InterestRateConfig, - MarginfiGroup, RiskTier, + Bank, BankConfigCompact, BankOperationalState, InterestRateConfig, MarginfiGroup, + RiskTier, }, price::OracleSetup, }, diff --git a/programs/marginfi/src/instructions/marginfi_group/mod.rs b/programs/marginfi/src/instructions/marginfi_group/mod.rs index 4d00f4854..2c3ca6500 100644 --- a/programs/marginfi/src/instructions/marginfi_group/mod.rs +++ b/programs/marginfi/src/instructions/marginfi_group/mod.rs @@ -1,17 +1,17 @@ mod accrue_bank_interest; mod add_pool; +mod add_pool_permissionless; mod collect_bank_fees; mod configure; mod configure_bank; mod handle_bankruptcy; mod initialize; -mod add_pool_permissionless; pub use accrue_bank_interest::*; pub use add_pool::*; +pub use add_pool_permissionless::*; pub use collect_bank_fees::*; pub use configure::*; pub use configure_bank::*; pub use handle_bankruptcy::*; pub use initialize::*; -pub use add_pool_permissionless::*; \ No newline at end of file diff --git a/programs/marginfi/src/state/marginfi_account.rs b/programs/marginfi/src/state/marginfi_account.rs index 0186c2f39..8477d2380 100644 --- a/programs/marginfi/src/state/marginfi_account.rs +++ b/programs/marginfi/src/state/marginfi_account.rs @@ -5,7 +5,9 @@ use super::{ use crate::{ assert_struct_align, assert_struct_size, check, constants::{ - ASSET_TAG_DEFAULT, BANKRUPT_THRESHOLD, EMISSIONS_FLAG_BORROW_ACTIVE, EMISSIONS_FLAG_LENDING_ACTIVE, EMPTY_BALANCE_THRESHOLD, EXP_10_I80F48, MIN_EMISSIONS_START_TIME, SECONDS_PER_YEAR, ZERO_AMOUNT_THRESHOLD + ASSET_TAG_DEFAULT, BANKRUPT_THRESHOLD, EMISSIONS_FLAG_BORROW_ACTIVE, + EMISSIONS_FLAG_LENDING_ACTIVE, EMPTY_BALANCE_THRESHOLD, EXP_10_I80F48, + MIN_EMISSIONS_START_TIME, SECONDS_PER_YEAR, ZERO_AMOUNT_THRESHOLD, }, debug, math_error, prelude::{MarginfiError, MarginfiResult}, From 530deb1666ffff120c94cbb089e95fee7110cd82 Mon Sep 17 00:00:00 2001 From: Jon Gurary Date: Tue, 24 Sep 2024 13:34:50 -0400 Subject: [PATCH 22/52] Fix CI attempt 2 --- .github/workflows/test.yaml | 9 +++++---- .../src/instructions/marginfi_account/deposit.rs | 2 +- tests/rootHooks.ts | 3 +++ 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 11f30fca2..14983a83a 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -139,6 +139,10 @@ jobs: - name: Build mocks program run: anchor build -p mocks + - name: Run tests + run: anchor test --skip-build + + # - name: Start Solana Test Validator # run: | # solana-test-validator --reset --limit-ledger-size 1000 \ @@ -153,7 +157,4 @@ jobs: # run: solana program deploy --program-id 2jGhuVUuy3umdzByFx8sNWUAaf5vaeuDm78RDPEnhrMr target/deploy/marginfi.so # - name: Deploy Mocks Program - # run: solana program deploy --program-id 5XaaR94jBubdbrRrNW7DtRvZeWvLhSHkEGU3jHTEXV3C target/deploy/mocks.so - - - name: Run tests - run: anchor test --skip-build + # run: solana program deploy --program-id 5XaaR94jBubdbrRrNW7DtRvZeWvLhSHkEGU3jHTEXV3C target/deploy/mocks.so \ No newline at end of file diff --git a/programs/marginfi/src/instructions/marginfi_account/deposit.rs b/programs/marginfi/src/instructions/marginfi_account/deposit.rs index 16ef18655..7b1e27889 100644 --- a/programs/marginfi/src/instructions/marginfi_account/deposit.rs +++ b/programs/marginfi/src/instructions/marginfi_account/deposit.rs @@ -1,6 +1,6 @@ use crate::{ check, - constants::{ASSET_TAG_DEFAULT, ASSET_TAG_SOL, ASSET_TAG_STAKED, LIQUIDITY_VAULT_SEED}, + constants::LIQUIDITY_VAULT_SEED, events::{AccountEventHeader, LendingAccountDepositEvent}, prelude::*, state::{ diff --git a/tests/rootHooks.ts b/tests/rootHooks.ts index 093c340af..82a570548 100644 --- a/tests/rootHooks.ts +++ b/tests/rootHooks.ts @@ -72,6 +72,9 @@ let copyKeys: PublicKey[] = []; export const mochaHooks = { beforeAll: async () => { + // If this fails you are in the wrong environment to run this test suite. + console.log("Crypto support: ", !!global.crypto?.subtle); + const mrgnProgram = workspace.Marginfi as Program; const provider = AnchorProvider.local(); const wallet = provider.wallet as Wallet; From 8a4261dc5f345fcb0de06f06263ef521a25c45bd Mon Sep 17 00:00:00 2001 From: Jon Gurary Date: Tue, 24 Sep 2024 13:37:32 -0400 Subject: [PATCH 23/52] Fix CI attempt 3 --- .github/workflows/test.yaml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 14983a83a..0b679d386 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -130,6 +130,11 @@ jobs: - name: Install Node.js dependencies run: yarn install + - name: Debug environment + run: | + node -v + printenv + - name: Build marginfi program run: anchor build -p marginfi -- --no-default-features @@ -142,7 +147,6 @@ jobs: - name: Run tests run: anchor test --skip-build - # - name: Start Solana Test Validator # run: | # solana-test-validator --reset --limit-ledger-size 1000 \ @@ -157,4 +161,4 @@ jobs: # run: solana program deploy --program-id 2jGhuVUuy3umdzByFx8sNWUAaf5vaeuDm78RDPEnhrMr target/deploy/marginfi.so # - name: Deploy Mocks Program - # run: solana program deploy --program-id 5XaaR94jBubdbrRrNW7DtRvZeWvLhSHkEGU3jHTEXV3C target/deploy/mocks.so \ No newline at end of file + # run: solana program deploy --program-id 5XaaR94jBubdbrRrNW7DtRvZeWvLhSHkEGU3jHTEXV3C target/deploy/mocks.so From 1c7dd35c9017e37481b00ee5d3067aaccd8df27a Mon Sep 17 00:00:00 2001 From: Jon Gurary Date: Tue, 24 Sep 2024 14:22:35 -0400 Subject: [PATCH 24/52] Fix CI attempt 4, add boilerplate for sol price appreciation as LST grows in value --- .github/workflows/test.yaml | 5 ++++ .../marginfi_group/cache_sol_ex_rate.rs | 24 +++++++++++++++++++ .../src/instructions/marginfi_group/mod.rs | 2 ++ programs/marginfi/src/state/marginfi_group.rs | 14 ++++++++++- .../tests/admin_actions/setup_bank.rs | 8 +++++-- programs/marginfi/tests/misc/regression.rs | 7 +++++- tests/rootHooks.ts | 4 ++-- tests/s01_usersStake.spec.ts | 7 +----- tests/s02_addBank.spec.ts | 7 +++--- 9 files changed, 63 insertions(+), 15 deletions(-) create mode 100644 programs/marginfi/src/instructions/marginfi_group/cache_sol_ex_rate.rs diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 0b679d386..dc2ac1dcf 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -122,6 +122,11 @@ jobs: steps: - uses: actions/checkout@v3 + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: "20.10.0" + - uses: ./.github/actions/setup-common/ - uses: ./.github/actions/setup-anchor-cli/ diff --git a/programs/marginfi/src/instructions/marginfi_group/cache_sol_ex_rate.rs b/programs/marginfi/src/instructions/marginfi_group/cache_sol_ex_rate.rs new file mode 100644 index 000000000..24bd524de --- /dev/null +++ b/programs/marginfi/src/instructions/marginfi_group/cache_sol_ex_rate.rs @@ -0,0 +1,24 @@ +// Permissionless ix to order a bank with ASSET_TAG_STAKED to cache the current exchange rate of +// SOL:LST (sol_appreciation_rate) on the active spl-single-pool + +use crate::{constants::ASSET_TAG_STAKED, state::marginfi_group::Bank, MarginfiResult}; +use anchor_lang::prelude::*; +use fixed::types::I80F48; + +#[derive(Accounts)] +pub struct CacheSolExRate<'info> { + #[account(mut)] + pub bank: AccountLoader<'info, Bank>, +} + +pub fn cache_sol_ex_rate(ctx: Context) -> MarginfiResult { + let mut bank = ctx.accounts.bank.load_mut()?; + + // This ix does not apply to non-staked assets, set the default value and exit + if bank.config.asset_tag != ASSET_TAG_STAKED { + bank.sol_appreciation_rate = I80F48::ONE.into(); + return Ok(()); + } + + Ok(()) +} diff --git a/programs/marginfi/src/instructions/marginfi_group/mod.rs b/programs/marginfi/src/instructions/marginfi_group/mod.rs index 2c3ca6500..bc87d2289 100644 --- a/programs/marginfi/src/instructions/marginfi_group/mod.rs +++ b/programs/marginfi/src/instructions/marginfi_group/mod.rs @@ -1,6 +1,7 @@ mod accrue_bank_interest; mod add_pool; mod add_pool_permissionless; +mod cache_sol_ex_rate; mod collect_bank_fees; mod configure; mod configure_bank; @@ -10,6 +11,7 @@ mod initialize; pub use accrue_bank_interest::*; pub use add_pool::*; pub use add_pool_permissionless::*; +pub use cache_sol_ex_rate::*; pub use collect_bank_fees::*; pub use configure::*; pub use configure_bank::*; diff --git a/programs/marginfi/src/state/marginfi_group.rs b/programs/marginfi/src/state/marginfi_group.rs index e291d9d39..4c52324fe 100644 --- a/programs/marginfi/src/state/marginfi_group.rs +++ b/programs/marginfi/src/state/marginfi_group.rs @@ -345,7 +345,17 @@ pub struct Bank { pub emissions_remaining: WrappedI80F48, pub emissions_mint: Pubkey, - pub _padding_0: [[u64; 2]; 28], + /// For banks where `config.asset_tag == ASSET_TAG_STAKED`, this defines the last-cached + /// exchange rate of LST to SOL, i.e. the price appreciation of the LST. For example, if this is + /// 1, then the LST trades 1:1 for SOL. If this is 1.1, then 1 LST can be exchange for 1.1 SOL. + /// + /// Currently, this cannot be less than 1 (but this may change if slashing is implemented) + /// + /// For banks where `config.asset_tag != ASSET_TAG_STAKED` this field does nothing and may be 0, + /// 1, or any other value. + pub sol_appreciation_rate: WrappedI80F48, + + pub _padding_0: [[u64; 2]; 27], pub _padding_1: [[u64; 2]; 32], // 16 * 2 * 32 = 1024B } @@ -392,6 +402,7 @@ impl Bank { emissions_rate: 0, emissions_remaining: I80F48::ZERO.into(), emissions_mint: Pubkey::default(), + sol_appreciation_rate: I80F48::ONE.into(), ..Default::default() } } @@ -1119,6 +1130,7 @@ pub struct BankConfig { /// Time window in seconds for the oracle price feed to be considered live. pub oracle_max_age: u16, + // Note: 6 bytes of padding to next 8 byte alignment, then end padding pub _padding: [u8; 38], } diff --git a/programs/marginfi/tests/admin_actions/setup_bank.rs b/programs/marginfi/tests/admin_actions/setup_bank.rs index 09629600c..4b850cb10 100644 --- a/programs/marginfi/tests/admin_actions/setup_bank.rs +++ b/programs/marginfi/tests/admin_actions/setup_bank.rs @@ -70,6 +70,7 @@ async fn add_bank_success() -> anyhow::Result<()> { emissions_rate, emissions_remaining, emissions_mint, + sol_appreciation_rate, _padding_0, _padding_1, .. // ignore internal padding @@ -99,8 +100,9 @@ async fn add_bank_success() -> anyhow::Result<()> { assert_eq!(emissions_rate, 0); assert_eq!(emissions_mint, Pubkey::new_from_array([0; 32])); assert_eq!(emissions_remaining, I80F48!(0.0).into()); + assert_eq!(sol_appreciation_rate, I80F48!(1.0).into()); - assert_eq!(_padding_0, <[[u64; 2]; 28] as Default>::default()); + assert_eq!(_padding_0, <[[u64; 2]; 27] as Default>::default()); assert_eq!(_padding_1, <[[u64; 2]; 32] as Default>::default()); // this is the only loosely checked field @@ -173,6 +175,7 @@ async fn add_bank_with_seed_success() -> anyhow::Result<()> { emissions_rate, emissions_remaining, emissions_mint, + sol_appreciation_rate, _padding_0, _padding_1, .. // ignore internal padding @@ -202,8 +205,9 @@ async fn add_bank_with_seed_success() -> anyhow::Result<()> { assert_eq!(emissions_rate, 0); assert_eq!(emissions_mint, Pubkey::new_from_array([0; 32])); assert_eq!(emissions_remaining, I80F48!(0.0).into()); + assert_eq!(sol_appreciation_rate, I80F48!(1.0).into()); - assert_eq!(_padding_0, <[[u64; 2]; 28] as Default>::default()); + assert_eq!(_padding_0, <[[u64; 2]; 27] as Default>::default()); assert_eq!(_padding_1, <[[u64; 2]; 32] as Default>::default()); // this is the only loosely checked field diff --git a/programs/marginfi/tests/misc/regression.rs b/programs/marginfi/tests/misc/regression.rs index 40e319e4b..733a8e6a6 100644 --- a/programs/marginfi/tests/misc/regression.rs +++ b/programs/marginfi/tests/misc/regression.rs @@ -663,8 +663,13 @@ async fn bank_field_values_reg() -> anyhow::Result<()> { bank.emissions_mint, pubkey!("2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo") ); + // legacy banks can have 0 for this field, it does nothing for banks not using ASSET_TAG_STAKED + assert_eq!( + I80F48::from(bank.sol_appreciation_rate), + I80F48::from_str("0").unwrap() + ); - assert_eq!(bank._padding_0, [[0, 0]; 28]); + assert_eq!(bank._padding_0, [[0, 0]; 27]); assert_eq!(bank._padding_1, [[0, 0]; 32]); Ok(()) diff --git a/tests/rootHooks.ts b/tests/rootHooks.ts index 82a570548..89b515fcb 100644 --- a/tests/rootHooks.ts +++ b/tests/rootHooks.ts @@ -72,8 +72,8 @@ let copyKeys: PublicKey[] = []; export const mochaHooks = { beforeAll: async () => { - // If this fails you are in the wrong environment to run this test suite. - console.log("Crypto support: ", !!global.crypto?.subtle); + // If this is false, you are in the wrong environment to run this test suite, try polyfill. + console.log("Environment supports crypto: ", !!global.crypto?.subtle); const mrgnProgram = workspace.Marginfi as Program; const provider = AnchorProvider.local(); diff --git a/tests/s01_usersStake.spec.ts b/tests/s01_usersStake.spec.ts index ae43f4678..8a8581712 100644 --- a/tests/s01_usersStake.spec.ts +++ b/tests/s01_usersStake.spec.ts @@ -1,4 +1,4 @@ -import { BN, Program, workspace } from "@coral-xyz/anchor"; +import { BN } from "@coral-xyz/anchor"; import { LAMPORTS_PER_SOL, PublicKey, @@ -27,13 +27,8 @@ import { getTokenBalance, } from "./utils/genericTests"; import { u64MAX_BN } from "./utils/types"; -import { - SinglePoolInstruction, - SinglePoolProgram, -} from "@solana/spl-single-pool-classic"; import { getAssociatedTokenAddressSync } from "@mrgnlabs/mrgn-common"; import { - decodeSinglePool, depositToSinglePoolIxes, getBankrunBlockhash, } from "./utils/spl-staking-utils"; diff --git a/tests/s02_addBank.spec.ts b/tests/s02_addBank.spec.ts index bc8778c58..a16676dfa 100644 --- a/tests/s02_addBank.spec.ts +++ b/tests/s02_addBank.spec.ts @@ -15,14 +15,13 @@ import { validators, verbose, } from "./rootHooks"; -import { - assertKeysEqual, -} from "./utils/genericTests"; +import { assertI80F48Equal, assertKeysEqual } from "./utils/genericTests"; import { ASSET_TAG_DEFAULT, ASSET_TAG_SOL, ASSET_TAG_STAKED, defaultBankConfig, + I80F48_ONE, } from "./utils/types"; import { assert } from "chai"; import { getBankrunBlockhash } from "./utils/spl-staking-utils"; @@ -137,6 +136,8 @@ describe("Init group and add banks with asset category flags", () => { const bank = await bankrunProgram.account.bank.fetch(validators[0].bank); assert.equal(bank.config.assetTag, ASSET_TAG_STAKED); + // Note: This field is set for all banks, but only relevant for ASSET_TAG_STAKED banks. + assertI80F48Equal(bank.solAppreciationRate, I80F48_ONE); }); // TODO add an LST-based pool without permission From 5c7261e810e8bc8fd9e7b44c87198ea21882c6c8 Mon Sep 17 00:00:00 2001 From: Jon Gurary Date: Tue, 24 Sep 2024 16:03:14 -0400 Subject: [PATCH 25/52] Fix CI attempt 5, add instruction to cache increases in LST value due to appreciation --- .github/workflows/test.yaml | 29 ++- Anchor.toml | 2 +- programs/brick/src/lib.rs | 7 + programs/marginfi/src/constants.rs | 11 + programs/marginfi/src/errors.rs | 2 + .../marginfi_group/cache_sol_ex_rate.rs | 54 ++++- programs/marginfi/src/lib.rs | 7 + tests/rootHooks.ts | 2 +- tests/s01_usersStake.spec.ts | 28 ++- tests/s03_deposit.spec.ts | 38 +++- tests/s05_solAppreciates.spec.ts | 191 ++++++++++++++++++ tests/utils/genericTests.ts | 5 +- tests/utils/group-instructions.ts | 26 ++- tests/utils/mocks.ts | 10 +- 14 files changed, 376 insertions(+), 36 deletions(-) create mode 100644 tests/s05_solAppreciates.spec.ts diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index dc2ac1dcf..ed8b42263 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -135,22 +135,31 @@ jobs: - name: Install Node.js dependencies run: yarn install - - name: Debug environment - run: | - node -v - printenv - - name: Build marginfi program run: anchor build -p marginfi -- --no-default-features - - name: Build liquidity incentive program - run: anchor build -p liquidity_incentive_program -- --no-default-features - - name: Build mocks program run: anchor build -p mocks - - name: Run tests - run: anchor test --skip-build + # Handles extraneous (os error 2) that appears during testing in some versions of solana. See: + # https://solana.stackexchange.com/questions/1648/error-no-such-file-or-directory-os-error-2-error-from-anchor-test + - name: Run Anchor tests + run: | + set -o pipefail + anchor test --skip-build 2>&1 | tee test_output.log + + if grep -q "failing" test_output.log; then + echo "Real test failure detected." + exit 1 + fi + + if grep -q "No such file or directory (os error 2)" test_output.log; then + echo "Extraneous error detected, ignoring it..." + exit 0 + else + echo "Test run completed without extraneous errors." + exit 0 + fi # - name: Start Solana Test Validator # run: | diff --git a/Anchor.toml b/Anchor.toml index 1c6a71991..274b801dc 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -7,7 +7,7 @@ resolution = true skip-lint = false [programs.localnet] -liquidity_incentive_program = "Lip1111111111111111111111111111111111111111" +# liquidity_incentive_program = "Lip1111111111111111111111111111111111111111" marginfi = "2jGhuVUuy3umdzByFx8sNWUAaf5vaeuDm78RDPEnhrMr" mocks = "5XaaR94jBubdbrRrNW7DtRvZeWvLhSHkEGU3jHTEXV3C" spl_single_pool = "SVSPxpvHdN29nkVg9rPapPNDddN5DipNLRUFhyjFThE" # cloned from solana-labs repo (see below) diff --git a/programs/brick/src/lib.rs b/programs/brick/src/lib.rs index 47f475e78..107285048 100644 --- a/programs/brick/src/lib.rs +++ b/programs/brick/src/lib.rs @@ -13,6 +13,10 @@ pub mod brick { ) -> Result<()> { Err(ErrorCode::ProgramDisabled.into()) } + + pub fn initialize(_ctx: Context, _val: u64) -> Result<()> { + Ok(()) + } } #[error_code] @@ -20,3 +24,6 @@ pub enum ErrorCode { #[msg("This program is temporarily disabled.")] ProgramDisabled, } + +#[derive(Accounts)] +pub struct Initialize {} diff --git a/programs/marginfi/src/constants.rs b/programs/marginfi/src/constants.rs index 7199665f5..38e20341a 100644 --- a/programs/marginfi/src/constants.rs +++ b/programs/marginfi/src/constants.rs @@ -26,6 +26,17 @@ cfg_if::cfg_if! { } } +// TODO update to the actual deployment key on mainnet/devnet/staging +cfg_if::cfg_if! { + if #[cfg(feature = "devnet")] { + pub const SPL_SINGLE_POOL_ID: Pubkey = pubkey!("SVSPxpvHdN29nkVg9rPapPNDddN5DipNLRUFhyjFThE"); + } else if #[cfg(any(feature = "mainnet-beta", feature = "staging"))] { + pub const SPL_SINGLE_POOL_ID: Pubkey = pubkey!("SVSPxpvHdN29nkVg9rPapPNDddN5DipNLRUFhyjFThE"); + } else { + pub const SPL_SINGLE_POOL_ID: Pubkey = pubkey!("SVSPxpvHdN29nkVg9rPapPNDddN5DipNLRUFhyjFThE"); + } +} + cfg_if::cfg_if! { if #[cfg(feature = "devnet")] { pub const SWITCHBOARD_PULL_ID: Pubkey = pubkey!("Aio4gaXjXzJNVLtzwtNVmSqGKpANtXhybbkhtAC94ji2"); diff --git a/programs/marginfi/src/errors.rs b/programs/marginfi/src/errors.rs index 1059fdaa7..3ff4c5778 100644 --- a/programs/marginfi/src/errors.rs +++ b/programs/marginfi/src/errors.rs @@ -98,6 +98,8 @@ pub enum MarginfiError { T22MintRequired, #[msg("Staked SOL accounts can only deposit staked assets and borrow SOL")] // 6047 AssetTagMismatch, + #[msg("Stake pool validation failed: check the stake pool, mint, or sol pool")] // 6048 + StakePoolValidationFailed, } impl From for ProgramError { diff --git a/programs/marginfi/src/instructions/marginfi_group/cache_sol_ex_rate.rs b/programs/marginfi/src/instructions/marginfi_group/cache_sol_ex_rate.rs index 24bd524de..f3321c82d 100644 --- a/programs/marginfi/src/instructions/marginfi_group/cache_sol_ex_rate.rs +++ b/programs/marginfi/src/instructions/marginfi_group/cache_sol_ex_rate.rs @@ -1,24 +1,76 @@ // Permissionless ix to order a bank with ASSET_TAG_STAKED to cache the current exchange rate of // SOL:LST (sol_appreciation_rate) on the active spl-single-pool -use crate::{constants::ASSET_TAG_STAKED, state::marginfi_group::Bank, MarginfiResult}; +use crate::{ + check, + constants::{ASSET_TAG_STAKED, SPL_SINGLE_POOL_ID}, + state::marginfi_group::Bank, + MarginfiError, MarginfiResult, +}; use anchor_lang::prelude::*; +use anchor_spl::token_interface::*; use fixed::types::I80F48; #[derive(Accounts)] pub struct CacheSolExRate<'info> { #[account(mut)] pub bank: AccountLoader<'info, Bank>, + + #[account( + constraint = lst_mint.key() == bank.load()?.mint @ MarginfiError::StakePoolValidationFailed + )] + pub lst_mint: Box>, + /// CHECK: Validated using `stake_pool` + pub sol_pool: AccountInfo<'info>, + + /// CHECK: We validate this is correct backwards, by deriving the PDA of the `lst_mint` using + /// this key. Since the mint is already checked against the known value on the Bank, if it + /// derives the same `lst_mint`, then this must be the correct pool, and we can subsequently use + /// it to validate the `sol_pool` + pub stake_pool: AccountInfo<'info>, } pub fn cache_sol_ex_rate(ctx: Context) -> MarginfiResult { let mut bank = ctx.accounts.bank.load_mut()?; + let stake_pool_bytes = &ctx.accounts.stake_pool.key().to_bytes(); // This ix does not apply to non-staked assets, set the default value and exit if bank.config.asset_tag != ASSET_TAG_STAKED { + bank.sol_appreciation_rate = I80F48::ONE.into(); + msg!("Wrong asset type flagged, resetting to default value and aborting"); + return Ok(()); + } + + let program_id = &SPL_SINGLE_POOL_ID; + // Validate the given stake_pool derives the same lst_mint, proving stake_pool is correct + let (exp_mint, _) = Pubkey::find_program_address(&[b"mint", stake_pool_bytes], program_id); + check!( + exp_mint == ctx.accounts.lst_mint.key(), + MarginfiError::StakePoolValidationFailed + ); + + // Validate the now-proven stake_pool derives the given sol_pool + let (exp_pool, _) = Pubkey::find_program_address(&[b"stake", stake_pool_bytes], program_id); + check!( + exp_pool == ctx.accounts.sol_pool.key(), + MarginfiError::StakePoolValidationFailed + ); + + // Note: LST mint and SOL use the same decimals, so decimals do not need to be considered + let lst_supply: I80F48 = I80F48::from(ctx.accounts.lst_mint.supply); + // Handle the edge case when the supply is zero + if lst_supply == I80F48::ZERO { bank.sol_appreciation_rate = I80F48::ONE.into(); return Ok(()); } + let sol_pool_balance: I80F48 = I80F48::from(ctx.accounts.sol_pool.lamports()); + + let sol_lst_exchange_rate: I80F48 = sol_pool_balance / lst_supply; + // Sanity check the exchange rate + if sol_lst_exchange_rate < I80F48::ONE { + panic!("invalid exchange rate or slashing now exists"); + } + bank.sol_appreciation_rate = sol_lst_exchange_rate.into(); Ok(()) } diff --git a/programs/marginfi/src/lib.rs b/programs/marginfi/src/lib.rs index d9dbd9f70..266c5ea4f 100644 --- a/programs/marginfi/src/lib.rs +++ b/programs/marginfi/src/lib.rs @@ -87,6 +87,13 @@ pub mod marginfi { ) } + /// (permissionless) Used by Staked Sol banks (`bank.config.asset_tag == ASSET_TAG_STAKED`) to + /// cache the current exchange rate of SOL:LST for that validator. Should be called roughly once + /// per epoch. + pub fn cache_sol_ex_rate(ctx: Context) -> MarginfiResult { + marginfi_group::cache_sol_ex_rate(ctx) + } + /// Handle bad debt of a bankrupt marginfi account for a given bank. pub fn lending_pool_handle_bankruptcy<'info>( ctx: Context<'_, '_, 'info, 'info, LendingPoolHandleBankruptcy<'info>>, diff --git a/tests/rootHooks.ts b/tests/rootHooks.ts index 89b515fcb..a29b52638 100644 --- a/tests/rootHooks.ts +++ b/tests/rootHooks.ts @@ -49,7 +49,7 @@ export let groupAdmin: MockUser = undefined; /** Administers valiator votes and withdraws */ export let validatorAdmin: MockUser = undefined; export const users: MockUser[] = []; -export const numUsers = 2; +export const numUsers = 3; export const validators: Validator[] = []; export const numValidators = 1; diff --git a/tests/s01_usersStake.spec.ts b/tests/s01_usersStake.spec.ts index 8a8581712..ecc39fc24 100644 --- a/tests/s01_usersStake.spec.ts +++ b/tests/s01_usersStake.spec.ts @@ -111,12 +111,21 @@ describe("User stakes some native and creates an account", () => { } }); - it("(user 1) Stakes and delegates too", async () => { - const user = users[1]; + it("(user 1/2) Stakes and delegates too", async () => { + await stakeAndDelegateForUser(1, stake); + await stakeAndDelegateForUser(2, stake); + }); + + const stakeAndDelegateForUser = async ( + userIndex: number, + stakeAmount: number + ) => { + const user = users[userIndex]; let { createTx, stakeAccountKeypair } = createStakeAccount( user, - stake * LAMPORTS_PER_SOL + stakeAmount * LAMPORTS_PER_SOL ); + createTx.recentBlockhash = await getBankrunBlockhash(bankrunContext); createTx.sign(user.wallet, stakeAccountKeypair); await banksClient.processTransaction(createTx); @@ -130,7 +139,7 @@ describe("User stakes some native and creates an account", () => { delegateTx.recentBlockhash = await getBankrunBlockhash(bankrunContext); delegateTx.sign(user.wallet); await banksClient.processTransaction(delegateTx); - }); + }; it("Advance the epoch", async () => { bankrunContext.warpToEpoch(1n); @@ -278,8 +287,13 @@ describe("User stakes some native and creates an account", () => { ); }); - it("(user 1) deposits to the stake pool too", async () => { - const user = users[1]; + it("(user 1/2) deposits to the stake pool too", async () => { + await depositForUser(1); + await depositForUser(2); + }); + + const depositForUser = async (userIndex: number) => { + const user = users[userIndex]; let tx = new Transaction(); const ixes = await depositToSinglePoolIxes( bankRunProvider.connection, @@ -298,5 +312,5 @@ describe("User stakes some native and creates an account", () => { user.wallet.publicKey ); user.accounts.set(LST_ATA, lstAta); - }); + }; }); diff --git a/tests/s03_deposit.spec.ts b/tests/s03_deposit.spec.ts index 9d5ee0259..9257bec81 100644 --- a/tests/s03_deposit.spec.ts +++ b/tests/s03_deposit.spec.ts @@ -9,33 +9,24 @@ import { import { Keypair, Transaction } from "@solana/web3.js"; import { Marginfi } from "../target/types/marginfi"; import { - bankKeypairA, bankKeypairSol, bankKeypairUsdc, bankrunContext, bankrunProgram, banksClient, ecosystem, - groupAdmin, marginfiGroup, - numUsers, users, validators, - verbose, } from "./rootHooks"; import { assertBankrunTxFailed, - assertBNApproximately, - assertI80F48Approx, - assertI80F48Equal, assertKeysEqual, - getTokenBalance, } from "./utils/genericTests"; import { assert } from "chai"; import { accountInit, depositIx } from "./utils/user-instructions"; import { LST_ATA, USER_ACCOUNT } from "./utils/mocks"; import { createMintToInstruction } from "@solana/spl-token"; -import { deriveLiquidityVault } from "./utils/pdas"; import { getBankrunBlockhash } from "./utils/spl-staking-utils"; import { BanksTransactionResultWithMeta } from "solana-bankrun"; @@ -233,4 +224,33 @@ describe("Deposit funds (included staked assets)", () => { const balances = userAcc.lendingAccount.balances; assert.equal(balances[2].active, false); }); + + it("(user 2) deposits to staked bank - should succeed", async () => { + const user = users[2]; + const userAccount = user.accounts.get(USER_ACCOUNT); + const userLstAta = user.accounts.get(LST_ATA); + + let tx = new Transaction().add( + await depositIx(program, { + marginfiGroup: marginfiGroup.publicKey, + marginfiAccount: userAccount, + authority: user.wallet.publicKey, + bank: validators[0].bank, + tokenAccount: userLstAta, + amount: new BN(1 * 10 ** ecosystem.wsolDecimals), + }) + ); + + tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + tx.sign(user.wallet); + await banksClient.tryProcessTransaction(tx); + + // Verify the deposit worked and the entry exists + const userAcc = await bankrunProgram.account.marginfiAccount.fetch( + userAccount + ); + const balances = userAcc.lendingAccount.balances; + assert.equal(balances[0].active, true); + assertKeysEqual(balances[0].bankPk, validators[0].bank); + }); }); diff --git a/tests/s05_solAppreciates.spec.ts b/tests/s05_solAppreciates.spec.ts new file mode 100644 index 000000000..1f38a9edc --- /dev/null +++ b/tests/s05_solAppreciates.spec.ts @@ -0,0 +1,191 @@ +import { + AnchorProvider, + BN, + getProvider, + Program, + Wallet, + workspace, +} from "@coral-xyz/anchor"; +import { + Keypair, + LAMPORTS_PER_SOL, + SystemProgram, + Transaction, +} from "@solana/web3.js"; +import { Marginfi } from "../target/types/marginfi"; +import { + bankKeypairA, + bankKeypairSol, + bankKeypairUsdc, + bankrunContext, + bankrunProgram, + bankRunProvider, + banksClient, + ecosystem, + groupAdmin, + marginfiGroup, + numUsers, + oracles, + users, + validators, + verbose, +} from "./rootHooks"; +import { + assertBankrunTxFailed, + assertBNApproximately, + assertI80F48Approx, + assertI80F48Equal, + assertKeysEqual, + getTokenBalance, +} from "./utils/genericTests"; +import { assert } from "chai"; +import { accountInit, borrowIx, depositIx } from "./utils/user-instructions"; +import { USER_ACCOUNT } from "./utils/mocks"; +import { createMintToInstruction } from "@solana/spl-token"; +import { deriveLiquidityVault } from "./utils/pdas"; +import { getBankrunBlockhash } from "./utils/spl-staking-utils"; +import { BanksTransactionResultWithMeta } from "solana-bankrun"; +import { cacheSolExchangeRate } from "./utils/group-instructions"; +import { I80F48_ONE } from "./utils/types"; + +describe("Deposit funds (included staked assets)", () => { + const program = workspace.Marginfi as Program; + const provider = getProvider() as AnchorProvider; + const wallet = provider.wallet as Wallet; + + // User 2 has a validator 0 staked depost [0] position - worth 1 LST token + // Users 0/1/2 deposited 10 SOL each, so a total of 30 is staked with validator 0 + /** SOL to add to the validator as pretend-earned epoch rewards */ + const appreciation = 30; + + it("(user 2) borrows 1.1 SOL against their STAKED position - fails, not enough funds", async () => { + const user = users[2]; + const userAccount = user.accounts.get(USER_ACCOUNT); + + let tx = new Transaction().add( + await borrowIx(program, { + marginfiGroup: marginfiGroup.publicKey, + marginfiAccount: userAccount, + authority: user.wallet.publicKey, + bank: bankKeypairSol.publicKey, + tokenAccount: user.wsolAccount, + remaining: [ + validators[0].bank, + oracles.wsolOracle.publicKey, + bankKeypairSol.publicKey, + oracles.wsolOracle.publicKey, + ], + amount: new BN(1.1 * 10 ** ecosystem.wsolDecimals), + }) + ); + tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + tx.sign(user.wallet); + let result = await banksClient.tryProcessTransaction(tx); + // 6010 (Generic risk engine rejection) + assertBankrunTxFailed(result, "0x177a"); + + const userAcc = await bankrunProgram.account.marginfiAccount.fetch( + userAccount + ); + const balances = userAcc.lendingAccount.balances; + assert.equal(balances[1].active, false); + }); + + // Note: there is some natural appreciation here because a few epochs have elapsed... + // TODO: Show math for expected appreciation due to epochs advancing + it("(permissionless) validator 0 cache stake - happy path (small change)", async () => { + let tx = new Transaction().add( + await cacheSolExchangeRate(program, { + bank: validators[0].bank, + lstMint: validators[0].splMint, + solPool: validators[0].splStake, + stakePool: validators[0].splPool, + }) + ); + tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + tx.sign(wallet.payer); // provider wallet pays the tx fee + await banksClient.processTransaction(tx); + + const bank = await bankrunProgram.account.bank.fetch(validators[0].bank); + assertI80F48Approx(bank.solAppreciationRate, 1.033, 0.01); + }); + + it("attacker tries to sneak a bad spl pool - should fail", async () => { + let tx = new Transaction().add( + await cacheSolExchangeRate(program, { + bank: validators[0].bank, + lstMint: validators[0].splMint, + solPool: wallet.publicKey, + stakePool: validators[0].splPool, + }) + ); + tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + tx.sign(wallet.payer); // provider wallet pays the tx fee + let result = await banksClient.tryProcessTransaction(tx); + // 6048 (Stake pool validation failed) + assertBankrunTxFailed(result, "0x17a0"); + }); + + // Here we mock epoch rewards by simply minting SOL into the validator's pool without staking + it("Validator 0 stake appreciates in value", async () => { + let tx = new Transaction(); + tx.add( + SystemProgram.transfer({ + fromPubkey: wallet.publicKey, + toPubkey: validators[0].splStake, + lamports: appreciation * LAMPORTS_PER_SOL, + }) + ); + tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + tx.sign(wallet.payer); + await banksClient.processTransaction(tx); + }); + + it("(permissionless) validator 0 cache stake - 1 LST is now worth 2 SOL", async () => { + // No appreciation yet, so no change... + let tx = new Transaction().add( + await cacheSolExchangeRate(program, { + bank: validators[0].bank, + lstMint: validators[0].splMint, + solPool: validators[0].splStake, + stakePool: validators[0].splPool, + }) + ); + tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + tx.sign(wallet.payer); // provider wallet pays the tx fee + await banksClient.processTransaction(tx); + + const bank = await bankrunProgram.account.bank.fetch(validators[0].bank); + assertI80F48Approx(bank.solAppreciationRate, 2.033, 0.01); + }); + + // The account is now worth enough for this borrow to succeed! + it("(user 2) borrows 1.1 SOL against their STAKED position - succeeds", async () => { + // const user = users[2]; + // const userAccount = user.accounts.get(USER_ACCOUNT); + // let tx = new Transaction().add( + // await borrowIx(program, { + // marginfiGroup: marginfiGroup.publicKey, + // marginfiAccount: userAccount, + // authority: user.wallet.publicKey, + // bank: bankKeypairSol.publicKey, + // tokenAccount: user.wsolAccount, + // remaining: [ + // validators[0].bank, + // oracles.wsolOracle.publicKey, + // bankKeypairSol.publicKey, + // oracles.wsolOracle.publicKey, + // ], + // amount: new BN(1.1 * 10 ** ecosystem.wsolDecimals), + // }) + // ); + // tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + // tx.sign(user.wallet); + // let result = await banksClient.processTransaction(tx); + // const userAcc = await bankrunProgram.account.marginfiAccount.fetch( + // userAccount + // ); + // const balances = userAcc.lendingAccount.balances; + // assert.equal(balances[1].active, false); + }); +}); diff --git a/tests/utils/genericTests.ts b/tests/utils/genericTests.ts index d1b52f588..18fa4d544 100644 --- a/tests/utils/genericTests.ts +++ b/tests/utils/genericTests.ts @@ -190,5 +190,8 @@ export const assertBankrunTxFailed = ( assert(result.meta.logMessages.length > 0); assert(result.result, "TX succeeded when it should have failed"); const lastLog = result.meta.logMessages.pop(); - assert(lastLog.includes(expectedErrorCode), "Actual error: " + lastLog); + assert( + lastLog.includes(expectedErrorCode), + "\nExpected code " + expectedErrorCode + " but got: " + lastLog + ); }; diff --git a/tests/utils/group-instructions.ts b/tests/utils/group-instructions.ts index 1a1c75822..67587cd4b 100644 --- a/tests/utils/group-instructions.ts +++ b/tests/utils/group-instructions.ts @@ -217,4 +217,28 @@ export const updateEmissions = ( }) .instruction(); return ix; -}; \ No newline at end of file +}; + +export type CacheSolExchangeRateArgs = { + bank: PublicKey; + lstMint: PublicKey; + solPool: PublicKey; + stakePool: PublicKey; +}; + +export const cacheSolExchangeRate = ( + program: Program, + args: CacheSolExchangeRateArgs +) => { + const ix = program.methods + .cacheSolExRate() + .accounts({ + bank: args.bank, + lstMint: args.lstMint, + solPool: args.solPool, + stakePool: args.stakePool, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .instruction(); + return ix; +}; diff --git a/tests/utils/mocks.ts b/tests/utils/mocks.ts index fbe0ee984..4c405e838 100644 --- a/tests/utils/mocks.ts +++ b/tests/utils/mocks.ts @@ -104,9 +104,9 @@ export type MockUser = { /** in mockUser.accounts, key used to get/set the users's account for group 0 */ export const USER_ACCOUNT: string = "g0_acc"; /** in mockUser.accounts, key used to get/set the users's LST ATA for validator 0 */ -export const LST_ATA = "v0_lstAta" +export const LST_ATA = "v0_lstAta"; /** in mockUser.accounts, key used to get/set the users's LST stake account for validator 0 */ -export const STAKE_ACC = "v0_stakeAcc" +export const STAKE_ACC = "v0_stakeAcc"; /** * Options to skip various parts of mock user setup @@ -362,11 +362,11 @@ export type Validator = { authorizedWithdrawer: PublicKey; voteAccount: PublicKey; splPool: PublicKey; - /** spl pool's mint for the LST */ + /** spl pool's mint for the LST (a PDA automatically created on init) */ splMint: PublicKey; - /** spl pool's authority for LST management (a PDA automatically created on init) */ + /** spl pool's authority for LST management, a PDA with no data/lamports */ splAuthority: PublicKey; - /** spl pool's stake account */ + /** spl pool's stake account (a PDA automatically created on init, contains the SOL held by the pool) */ splStake: PublicKey; /** bank created for this validator's LST on the "main" group */ bank: PublicKey; From 3e0a0bdd5be375466a599f7dfa10855f186336cd Mon Sep 17 00:00:00 2001 From: Jon Gurary Date: Tue, 24 Sep 2024 16:22:00 -0400 Subject: [PATCH 26/52] Now uses SOL price appreciation when determining the value of staked SOL assets --- .github/workflows/test.yaml | 2 +- .../marginfi/src/state/marginfi_account.rs | 10 ++- programs/marginfi/src/state/marginfi_group.rs | 2 +- tests/s01_usersStake.spec.ts | 4 +- tests/s05_solAppreciates.spec.ts | 80 +++++++++++-------- 5 files changed, 61 insertions(+), 37 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index ed8b42263..25b5e803c 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -130,7 +130,7 @@ jobs: - uses: ./.github/actions/setup-common/ - uses: ./.github/actions/setup-anchor-cli/ - - uses: ./.github/actions/build-workspace/ + # - uses: ./.github/actions/build-workspace/ - name: Install Node.js dependencies run: yarn install diff --git a/programs/marginfi/src/state/marginfi_account.rs b/programs/marginfi/src/state/marginfi_account.rs index 8477d2380..42c59754f 100644 --- a/programs/marginfi/src/state/marginfi_account.rs +++ b/programs/marginfi/src/state/marginfi_account.rs @@ -5,7 +5,7 @@ use super::{ use crate::{ assert_struct_align, assert_struct_size, check, constants::{ - ASSET_TAG_DEFAULT, BANKRUPT_THRESHOLD, EMISSIONS_FLAG_BORROW_ACTIVE, + ASSET_TAG_DEFAULT, ASSET_TAG_STAKED, BANKRUPT_THRESHOLD, EMISSIONS_FLAG_BORROW_ACTIVE, EMISSIONS_FLAG_LENDING_ACTIVE, EMPTY_BALANCE_THRESHOLD, EXP_10_I80F48, MIN_EMISSIONS_START_TIME, SECONDS_PER_YEAR, ZERO_AMOUNT_THRESHOLD, }, @@ -296,6 +296,12 @@ impl<'info> BankAccountWithPriceFeed<'_, 'info> { } } + if bank.config.asset_tag == ASSET_TAG_STAKED { + asset_weight = asset_weight + .checked_mul(bank.sol_appreciation_rate.into()) + .ok_or_else(math_error!())?; + } + calc_value( bank.get_asset_amount(self.balance.asset_shares.into())?, lower_price, @@ -323,6 +329,8 @@ impl<'info> BankAccountWithPriceFeed<'_, 'info> { Some(PriceBias::High), )?; + // If `ASSET_TAG_STAKED` assets can ever be borrowed, accomodate for that here... + calc_value( bank.get_liability_amount(self.balance.liability_shares.into())?, higher_price, diff --git a/programs/marginfi/src/state/marginfi_group.rs b/programs/marginfi/src/state/marginfi_group.rs index 4c52324fe..718b5f140 100644 --- a/programs/marginfi/src/state/marginfi_group.rs +++ b/programs/marginfi/src/state/marginfi_group.rs @@ -350,7 +350,7 @@ pub struct Bank { /// 1, then the LST trades 1:1 for SOL. If this is 1.1, then 1 LST can be exchange for 1.1 SOL. /// /// Currently, this cannot be less than 1 (but this may change if slashing is implemented) - /// + /// /// For banks where `config.asset_tag != ASSET_TAG_STAKED` this field does nothing and may be 0, /// 1, or any other value. pub sol_appreciation_rate: WrappedI80F48, diff --git a/tests/s01_usersStake.spec.ts b/tests/s01_usersStake.spec.ts index ecc39fc24..4b19386f2 100644 --- a/tests/s01_usersStake.spec.ts +++ b/tests/s01_usersStake.spec.ts @@ -199,7 +199,7 @@ describe("User stakes some native and creates an account", () => { } }); - it("(user 0) Deposits stake to the LST pool", async () => { + it("(user 0) Deposits " + stake + "stake to the v0 LST pool", async () => { const userStakeAccount = users[0].accounts.get(STAKE_ACC); // Note: use `findPoolMintAddress(SINGLE_POOL_PROGRAM_ID, splPool);` if mint is not known. const lstAta = getAssociatedTokenAddressSync( @@ -287,7 +287,7 @@ describe("User stakes some native and creates an account", () => { ); }); - it("(user 1/2) deposits to the stake pool too", async () => { + it("(user 1/2) deposits " + stake + " to the v0 stake pool too", async () => { await depositForUser(1); await depositForUser(2); }); diff --git a/tests/s05_solAppreciates.spec.ts b/tests/s05_solAppreciates.spec.ts index 1f38a9edc..44021d970 100644 --- a/tests/s05_solAppreciates.spec.ts +++ b/tests/s05_solAppreciates.spec.ts @@ -47,18 +47,19 @@ import { getBankrunBlockhash } from "./utils/spl-staking-utils"; import { BanksTransactionResultWithMeta } from "solana-bankrun"; import { cacheSolExchangeRate } from "./utils/group-instructions"; import { I80F48_ONE } from "./utils/types"; +import { wrappedI80F48toBigNumber } from "@mrgnlabs/mrgn-common"; describe("Deposit funds (included staked assets)", () => { const program = workspace.Marginfi as Program; const provider = getProvider() as AnchorProvider; const wallet = provider.wallet as Wallet; - // User 2 has a validator 0 staked depost [0] position - worth 1 LST token + // User 2 has a validator 0 staked depost [0] position - net value = 1 LST token // Users 0/1/2 deposited 10 SOL each, so a total of 30 is staked with validator 0 /** SOL to add to the validator as pretend-earned epoch rewards */ const appreciation = 30; - it("(user 2) borrows 1.1 SOL against their STAKED position - fails, not enough funds", async () => { + it("(user 2) tries to borrow 1.1 SOL against 1 v0 STAKED - fails, not enough funds", async () => { const user = users[2]; const userAccount = user.accounts.get(USER_ACCOUNT); @@ -93,7 +94,7 @@ describe("Deposit funds (included staked assets)", () => { // Note: there is some natural appreciation here because a few epochs have elapsed... // TODO: Show math for expected appreciation due to epochs advancing - it("(permissionless) validator 0 cache stake - happy path (small change)", async () => { + it("(permissionless) v0 cache stake - happy path (natural appreciation)", async () => { let tx = new Transaction().add( await cacheSolExchangeRate(program, { bank: validators[0].bank, @@ -107,10 +108,17 @@ describe("Deposit funds (included staked assets)", () => { await banksClient.processTransaction(tx); const bank = await bankrunProgram.account.bank.fetch(validators[0].bank); + if (verbose) { + console.log( + "1 [validator 0 LST token] is now worth: " + + wrappedI80F48toBigNumber(bank.solAppreciationRate).toString() + + " SOL" + ); + } assertI80F48Approx(bank.solAppreciationRate, 1.033, 0.01); }); - it("attacker tries to sneak a bad spl pool - should fail", async () => { + it("(attacker) tries to sneak a bad spl pool - should fail", async () => { let tx = new Transaction().add( await cacheSolExchangeRate(program, { bank: validators[0].bank, @@ -127,7 +135,7 @@ describe("Deposit funds (included staked assets)", () => { }); // Here we mock epoch rewards by simply minting SOL into the validator's pool without staking - it("Validator 0 stake appreciates in value", async () => { + it("v0 stake grows by " + appreciation + " SOL", async () => { let tx = new Transaction(); tx.add( SystemProgram.transfer({ @@ -142,7 +150,6 @@ describe("Deposit funds (included staked assets)", () => { }); it("(permissionless) validator 0 cache stake - 1 LST is now worth 2 SOL", async () => { - // No appreciation yet, so no change... let tx = new Transaction().add( await cacheSolExchangeRate(program, { bank: validators[0].bank, @@ -156,36 +163,45 @@ describe("Deposit funds (included staked assets)", () => { await banksClient.processTransaction(tx); const bank = await bankrunProgram.account.bank.fetch(validators[0].bank); + if (verbose) { + console.log( + "1 [validator 0 LST token] is now worth: " + + wrappedI80F48toBigNumber(bank.solAppreciationRate).toString() + + " SOL" + ); + } assertI80F48Approx(bank.solAppreciationRate, 2.033, 0.01); }); // The account is now worth enough for this borrow to succeed! it("(user 2) borrows 1.1 SOL against their STAKED position - succeeds", async () => { - // const user = users[2]; - // const userAccount = user.accounts.get(USER_ACCOUNT); - // let tx = new Transaction().add( - // await borrowIx(program, { - // marginfiGroup: marginfiGroup.publicKey, - // marginfiAccount: userAccount, - // authority: user.wallet.publicKey, - // bank: bankKeypairSol.publicKey, - // tokenAccount: user.wsolAccount, - // remaining: [ - // validators[0].bank, - // oracles.wsolOracle.publicKey, - // bankKeypairSol.publicKey, - // oracles.wsolOracle.publicKey, - // ], - // amount: new BN(1.1 * 10 ** ecosystem.wsolDecimals), - // }) - // ); - // tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); - // tx.sign(user.wallet); - // let result = await banksClient.processTransaction(tx); - // const userAcc = await bankrunProgram.account.marginfiAccount.fetch( - // userAccount - // ); - // const balances = userAcc.lendingAccount.balances; - // assert.equal(balances[1].active, false); + const user = users[2]; + const userAccount = user.accounts.get(USER_ACCOUNT); + let tx = new Transaction().add( + await borrowIx(program, { + marginfiGroup: marginfiGroup.publicKey, + marginfiAccount: userAccount, + authority: user.wallet.publicKey, + bank: bankKeypairSol.publicKey, + tokenAccount: user.wsolAccount, + remaining: [ + validators[0].bank, + oracles.wsolOracle.publicKey, + bankKeypairSol.publicKey, + oracles.wsolOracle.publicKey, + ], + amount: new BN(1.1 * 10 ** ecosystem.wsolDecimals), + }) + ); + tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + tx.sign(user.wallet); + await banksClient.processTransaction(tx); + + const userAcc = await bankrunProgram.account.marginfiAccount.fetch( + userAccount + ); + const balances = userAcc.lendingAccount.balances; + assert.equal(balances[1].active, true); + assertKeysEqual(balances[1].bankPk, bankKeypairSol.publicKey); }); }); From fc853edccfbe7f1a0b0d85998c969bcdb2a3e83d Mon Sep 17 00:00:00 2001 From: Jon Gurary Date: Tue, 24 Sep 2024 16:52:34 -0400 Subject: [PATCH 27/52] Fix CI attempt 6 --- Anchor.toml | 2 +- tests/s03_deposit.spec.ts | 1 - tests/s05_solAppreciates.spec.ts | 23 +++-------------------- 3 files changed, 4 insertions(+), 22 deletions(-) diff --git a/Anchor.toml b/Anchor.toml index 274b801dc..f8e1d6d2d 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -31,7 +31,7 @@ test = "RUST_LOG= yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/*.spec.t # test = "RUST_LOG= yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/s*.spec.ts --exit --require tests/rootHooks.ts" [test] -startup_wait = 20000 +startup_wait = 60000 shutdown_wait = 2000 upgradeable = false diff --git a/tests/s03_deposit.spec.ts b/tests/s03_deposit.spec.ts index 9257bec81..cc8a5601c 100644 --- a/tests/s03_deposit.spec.ts +++ b/tests/s03_deposit.spec.ts @@ -28,7 +28,6 @@ import { accountInit, depositIx } from "./utils/user-instructions"; import { LST_ATA, USER_ACCOUNT } from "./utils/mocks"; import { createMintToInstruction } from "@solana/spl-token"; import { getBankrunBlockhash } from "./utils/spl-staking-utils"; -import { BanksTransactionResultWithMeta } from "solana-bankrun"; describe("Deposit funds (included staked assets)", () => { const program = workspace.Marginfi as Program; diff --git a/tests/s05_solAppreciates.spec.ts b/tests/s05_solAppreciates.spec.ts index 44021d970..d7cd4ab10 100644 --- a/tests/s05_solAppreciates.spec.ts +++ b/tests/s05_solAppreciates.spec.ts @@ -6,25 +6,15 @@ import { Wallet, workspace, } from "@coral-xyz/anchor"; -import { - Keypair, - LAMPORTS_PER_SOL, - SystemProgram, - Transaction, -} from "@solana/web3.js"; +import { LAMPORTS_PER_SOL, SystemProgram, Transaction } from "@solana/web3.js"; import { Marginfi } from "../target/types/marginfi"; import { - bankKeypairA, bankKeypairSol, - bankKeypairUsdc, bankrunContext, bankrunProgram, - bankRunProvider, banksClient, ecosystem, - groupAdmin, marginfiGroup, - numUsers, oracles, users, validators, @@ -32,24 +22,17 @@ import { } from "./rootHooks"; import { assertBankrunTxFailed, - assertBNApproximately, assertI80F48Approx, - assertI80F48Equal, assertKeysEqual, - getTokenBalance, } from "./utils/genericTests"; import { assert } from "chai"; -import { accountInit, borrowIx, depositIx } from "./utils/user-instructions"; +import { borrowIx } from "./utils/user-instructions"; import { USER_ACCOUNT } from "./utils/mocks"; -import { createMintToInstruction } from "@solana/spl-token"; -import { deriveLiquidityVault } from "./utils/pdas"; import { getBankrunBlockhash } from "./utils/spl-staking-utils"; -import { BanksTransactionResultWithMeta } from "solana-bankrun"; import { cacheSolExchangeRate } from "./utils/group-instructions"; -import { I80F48_ONE } from "./utils/types"; import { wrappedI80F48toBigNumber } from "@mrgnlabs/mrgn-common"; -describe("Deposit funds (included staked assets)", () => { +describe("Borrow power grows as v0 Staked SOL gains value from appreciation", () => { const program = workspace.Marginfi as Program; const provider = getProvider() as AnchorProvider; const wallet = provider.wallet as Wallet; From 2f1e1caa5b59e5fcbf17f9530a429a7d5b773526 Mon Sep 17 00:00:00 2001 From: Jon Gurary Date: Tue, 24 Sep 2024 17:19:12 -0400 Subject: [PATCH 28/52] Fix CI attempt 7 --- Anchor.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Anchor.toml b/Anchor.toml index f8e1d6d2d..e2d94401b 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -31,7 +31,7 @@ test = "RUST_LOG= yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/*.spec.t # test = "RUST_LOG= yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/s*.spec.ts --exit --require tests/rootHooks.ts" [test] -startup_wait = 60000 +startup_wait = 120000 shutdown_wait = 2000 upgradeable = false From 1c1d22d1053eebb8783cb11307324b0a54396654 Mon Sep 17 00:00:00 2001 From: Jon Gurary Date: Tue, 24 Sep 2024 18:07:37 -0400 Subject: [PATCH 29/52] Fix CI attempt 8 --- Anchor.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Anchor.toml b/Anchor.toml index e2d94401b..d6b51b81d 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -10,7 +10,7 @@ skip-lint = false # liquidity_incentive_program = "Lip1111111111111111111111111111111111111111" marginfi = "2jGhuVUuy3umdzByFx8sNWUAaf5vaeuDm78RDPEnhrMr" mocks = "5XaaR94jBubdbrRrNW7DtRvZeWvLhSHkEGU3jHTEXV3C" -spl_single_pool = "SVSPxpvHdN29nkVg9rPapPNDddN5DipNLRUFhyjFThE" # cloned from solana-labs repo (see below) +spl_single_pool = "SVSPxpvHdN29nkVg9rPapPNDddN5DipNLRUFhyjFThE" # cloned from solana-labs repo (see below) [programs.mainnet] liquidity_incentive_program = "LipsxuAkFkwa4RKNzn51wAsW7Dedzt1RNHMkTkDEZUW" @@ -27,11 +27,11 @@ wallet = "~/.config/solana/id.json" [scripts] test = "RUST_LOG= yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/*.spec.ts --exit --require tests/rootHooks.ts" -# Staking Collatizer only +# Staked collateral tests only # test = "RUST_LOG= yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/s*.spec.ts --exit --require tests/rootHooks.ts" [test] -startup_wait = 120000 +startup_wait = 240000 shutdown_wait = 2000 upgradeable = false From 88b3f5c530600776cfd46d0a48ab7cd79f159be4 Mon Sep 17 00:00:00 2001 From: Jon Gurary Date: Tue, 24 Sep 2024 19:36:42 -0400 Subject: [PATCH 30/52] Fix CI attempt 9 --- .github/workflows/test.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 25b5e803c..a9c219f77 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -130,7 +130,7 @@ jobs: - uses: ./.github/actions/setup-common/ - uses: ./.github/actions/setup-anchor-cli/ - # - uses: ./.github/actions/build-workspace/ + - uses: ./.github/actions/build-workspace/ - name: Install Node.js dependencies run: yarn install @@ -138,6 +138,9 @@ jobs: - name: Build marginfi program run: anchor build -p marginfi -- --no-default-features + - name: Build liquidity incentive program + run: anchor build -p liquidity_incentive_program -- --no-default-features + - name: Build mocks program run: anchor build -p mocks From 73e74ce852c524652408d69b2385a6b489b2544c Mon Sep 17 00:00:00 2001 From: Jon Gurary Date: Tue, 24 Sep 2024 22:09:17 -0400 Subject: [PATCH 31/52] Fix CI attempt 10 --- .github/workflows/test.yaml | 16 ++++++++++------ Anchor.toml | 2 +- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index a9c219f77..fa411c538 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -138,9 +138,6 @@ jobs: - name: Build marginfi program run: anchor build -p marginfi -- --no-default-features - - name: Build liquidity incentive program - run: anchor build -p liquidity_incentive_program -- --no-default-features - - name: Build mocks program run: anchor build -p mocks @@ -148,9 +145,11 @@ jobs: # https://solana.stackexchange.com/questions/1648/error-no-such-file-or-directory-os-error-2-error-from-anchor-test - name: Run Anchor tests run: | - set -o pipefail + set +e anchor test --skip-build 2>&1 | tee test_output.log - + ANCHOR_EXIT_CODE=$? + set -e + if grep -q "failing" test_output.log; then echo "Real test failure detected." exit 1 @@ -159,8 +158,13 @@ jobs: if grep -q "No such file or directory (os error 2)" test_output.log; then echo "Extraneous error detected, ignoring it..." exit 0 + fi + + if [ $ANCHOR_EXIT_CODE -ne 0 ]; then + echo "Anchor test exited with code $ANCHOR_EXIT_CODE due to an unexpected error." + exit 1 else - echo "Test run completed without extraneous errors." + echo "Test run completed successfully without extraneous errors." exit 0 fi diff --git a/Anchor.toml b/Anchor.toml index d6b51b81d..a55cd323b 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -31,7 +31,7 @@ test = "RUST_LOG= yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/*.spec.t # test = "RUST_LOG= yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/s*.spec.ts --exit --require tests/rootHooks.ts" [test] -startup_wait = 240000 +startup_wait = 60000 shutdown_wait = 2000 upgradeable = false From 11288079cb2d30169c9f2359a2b9b8f5c4efa0ef Mon Sep 17 00:00:00 2001 From: jgur-psyops Date: Wed, 23 Oct 2024 19:02:35 -0400 Subject: [PATCH 32/52] Resolve merge with main, add test of origination fees to TS suite --- tests/03_addBank.spec.ts | 8 ++++++ tests/07_deposit.spec.ts | 1 - tests/08_borrow.spec.ts | 61 +++++++++++++++++++++++++++++++++++----- tests/utils/types.ts | 17 +++++++++-- 4 files changed, 77 insertions(+), 10 deletions(-) diff --git a/tests/03_addBank.spec.ts b/tests/03_addBank.spec.ts index 0c1280f3a..14507da97 100644 --- a/tests/03_addBank.spec.ts +++ b/tests/03_addBank.spec.ts @@ -143,6 +143,8 @@ describe("Lending pool add bank (add bank to group)", () => { assertI80F48Approx(interest.protocolFixedFeeApr, 0.03, tolerance); assertI80F48Approx(interest.protocolIrFee, 0.04, tolerance); + assertI80F48Approx(interest.protocolOriginationFee, 0.01, tolerance); + assert.deepEqual(config.operationalState, { operational: {} }); assert.deepEqual(config.oracleSetup, { pythLegacy: {} }); assertBNEqual(config.borrowLimit, 100_000_000_000); @@ -265,6 +267,9 @@ describe("Lending pool add bank (add bank to group)", () => { // assertI80F48Equal(interest.protocolFixedFeeApr, 0); // assertI80F48Equal(interest.protocolIrFee, 0); + // Bank added before this feature existed, should be zero + assertI80F48Equal(bonkInterest.protocolOriginationFee, 0); + assert.deepEqual(bonkConfig.operationalState, { operational: {} }); assert.deepEqual(bonkConfig.oracleSetup, { pythPushOracle: {} }); // roughly 26.41 billion BONK with 5 decimals. @@ -312,6 +317,9 @@ describe("Lending pool add bank (add bank to group)", () => { // 1 million CLOUD with 9 decimals (1_000_000_000_000_000) assertBNEqual(cloudConfig.depositLimit, 1_000_000_000_000_000); + // Bank added before this feature existed, should be zero + assertI80F48Equal(cloudInterest.protocolOriginationFee, 0); + assert.deepEqual(cloudConfig.operationalState, { operational: {} }); assert.deepEqual(cloudConfig.oracleSetup, { switchboardV2: {} }); // 50,000 CLOUD with 9 decimals (50_000_000_000_000) diff --git a/tests/07_deposit.spec.ts b/tests/07_deposit.spec.ts index 0fe8fa33e..42bf5ef31 100644 --- a/tests/07_deposit.spec.ts +++ b/tests/07_deposit.spec.ts @@ -115,7 +115,6 @@ describe("Deposit funds", () => { console.log("vault A after: " + vaultAAfter.toLocaleString()); } assert.equal(userABefore - depositAmountA_native.toNumber(), userAAfter); - // TODO this will change when origination fees are implemented. assert.equal(vaultABefore + depositAmountA_native.toNumber(), vaultAAfter); }); diff --git a/tests/08_borrow.spec.ts b/tests/08_borrow.spec.ts index cb96a3c2d..76ad07a40 100644 --- a/tests/08_borrow.spec.ts +++ b/tests/08_borrow.spec.ts @@ -28,6 +28,7 @@ import { borrowIx, depositIx } from "./utils/user-instructions"; import { USER_ACCOUNT } from "./utils/mocks"; import { createMintToInstruction } from "@solana/spl-token"; import { updatePriceAccount } from "./utils/pyth_mocks"; +import { wrappedI80F48toBigNumber } from "@mrgnlabs/mrgn-common"; describe("Borrow funds", () => { const program = workspace.Marginfi as Program; @@ -81,9 +82,23 @@ describe("Borrow funds", () => { it("(user 0) borrows USDC against their token A position - happy path", async () => { const user = users[0]; + const bank = bankKeypairUsdc.publicKey; const userUsdcBefore = await getTokenBalance(provider, user.usdcAccount); + const bankBefore = await program.account.bank.fetch(bank); if (verbose) { console.log("user 0 USDC before: " + userUsdcBefore.toLocaleString()); + console.log( + "usdc fees owed to bank: " + + wrappedI80F48toBigNumber( + bankBefore.collectedGroupFeesOutstanding + ).toString() + ); + console.log( + "usdc fees owed to program: " + + wrappedI80F48toBigNumber( + bankBefore.collectedProgramFeesOutstanding + ).toString() + ); } const user0Account = user.accounts.get(USER_ACCOUNT); @@ -94,12 +109,12 @@ describe("Borrow funds", () => { marginfiGroup: marginfiGroup.publicKey, marginfiAccount: user0Account, authority: user.wallet.publicKey, - bank: bankKeypairUsdc.publicKey, + bank: bank, tokenAccount: user.usdcAccount, remaining: [ bankKeypairA.publicKey, oracles.tokenAOracle.publicKey, - bankKeypairUsdc.publicKey, + bank, oracles.usdcOracle.publicKey, ], amount: borrowAmountUsdc_native, @@ -108,23 +123,55 @@ describe("Borrow funds", () => { ); const userAcc = await program.account.marginfiAccount.fetch(user0Account); + const bankAfter = await program.account.bank.fetch(bank); const balances = userAcc.lendingAccount.balances; + const userUsdcAfter = await getTokenBalance(provider, user.usdcAccount); + if (verbose) { + console.log("user 0 USDC after: " + userUsdcAfter.toLocaleString()); + console.log( + "usdc fees owed to bank: " + + wrappedI80F48toBigNumber( + bankAfter.collectedGroupFeesOutstanding + ).toString() + ); + console.log( + "usdc fees owed to program: " + + wrappedI80F48toBigNumber( + bankAfter.collectedProgramFeesOutstanding + ).toString() + ); + } + assert.equal(balances[1].active, true); assertI80F48Equal(balances[1].assetShares, 0); // Note: The first borrow issues shares 1:1 and the shares use the same decimals - assertI80F48Approx(balances[1].liabilityShares, borrowAmountUsdc_native); + // Note: An origination fee of 0.01 is also incurred here (configured during addBank) + const originationFee_native = borrowAmountUsdc_native.toNumber() * 0.01; + const amtUsdcWithFee_native = new BN( + borrowAmountUsdc_native.toNumber() + originationFee_native + ); + assertI80F48Approx(balances[1].liabilityShares, amtUsdcWithFee_native); assertI80F48Equal(balances[1].emissionsOutstanding, 0); let now = Math.floor(Date.now() / 1000); assertBNApproximately(balances[1].lastUpdate, now, 2); - const userUsdcAfter = await getTokenBalance(provider, user.usdcAccount); - if (verbose) { - console.log("user 0 USDC after: " + userUsdcAfter.toLocaleString()); - } assert.equal( userUsdcAfter - borrowAmountUsdc_native.toNumber(), userUsdcBefore ); + + // The origination fee is recorded on the bank. The group gets 98%, the program gets the + // remaining 2% (see PROGRAM_FEE_RATE) + const origination_fee_group = originationFee_native * 0.98; + const origination_fee_program = originationFee_native * 0.02; + assertI80F48Approx( + bankAfter.collectedGroupFeesOutstanding, + origination_fee_group + ); + assertI80F48Approx( + bankAfter.collectedProgramFeesOutstanding, + origination_fee_program + ); }); }); diff --git a/tests/utils/types.ts b/tests/utils/types.ts index b3c97d616..bf0f674a4 100644 --- a/tests/utils/types.ts +++ b/tests/utils/types.ts @@ -158,10 +158,11 @@ export const defaultBankConfigOptRaw = () => { * * insuranceIrFee = .02 * * protocolFixedFeeApr = .03 * * protocolIrFee = .04 + * * originationFee = .01 * @returns */ export const defaultInterestRateConfigRaw = () => { - let config: InterestRateConfigRaw = { + let config: InterestRateConfigRawWithOrigination = { optimalUtilizationRate: bigNumberToWrappedI80F48(0.5), plateauInterestRate: bigNumberToWrappedI80F48(0.6), maxInterestRate: bigNumberToWrappedI80F48(3), @@ -169,6 +170,7 @@ export const defaultInterestRateConfigRaw = () => { insuranceIrFee: bigNumberToWrappedI80F48(0.02), protocolFixedFeeApr: bigNumberToWrappedI80F48(0.03), protocolIrFee: bigNumberToWrappedI80F48(0.04), + protocolOriginationFee: bigNumberToWrappedI80F48(0.01), }; return config; }; @@ -178,7 +180,7 @@ export const defaultInterestRateConfigRaw = () => { * @returns */ export const defaultInterestRateConfig = () => { - let config: InterestRateConfig = { + let config: InterestRateConfigWithOrigination = { optimalUtilizationRate: new BigNumber(0.5), plateauInterestRate: new BigNumber(0.6), maxInterestRate: new BigNumber(3), @@ -186,6 +188,7 @@ export const defaultInterestRateConfig = () => { insuranceIrFee: new BigNumber(0), protocolFixedFeeApr: new BigNumber(0), protocolIrFee: new BigNumber(0), + protocolOriginationFee: new BigNumber(0.1), }; return config; }; @@ -194,3 +197,13 @@ export const defaultInterestRateConfig = () => { export type BankConfigOptWithAssetTag = BankConfigOptRaw & { assetTag: number | null; }; + +// TODO remove when package updates +export type InterestRateConfigRawWithOrigination = InterestRateConfigRaw & { + protocolOriginationFee: WrappedI80F48; +}; + +// TODO remove when package updates +export type InterestRateConfigWithOrigination = InterestRateConfig & { + protocolOriginationFee: BigNumber; +}; From 33388521397ed745bbd078723ee9f9687741443d Mon Sep 17 00:00:00 2001 From: jgur-psyops Date: Thu, 24 Oct 2024 22:11:30 -0400 Subject: [PATCH 33/52] Kludge for lint issue --- clients/rust/marginfi-cli/src/entrypoint.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/clients/rust/marginfi-cli/src/entrypoint.rs b/clients/rust/marginfi-cli/src/entrypoint.rs index ab45db803..fa3918987 100644 --- a/clients/rust/marginfi-cli/src/entrypoint.rs +++ b/clients/rust/marginfi-cli/src/entrypoint.rs @@ -214,6 +214,7 @@ impl From for BankOperationalState { } } +#[allow(clippy::large_enum_variant)] #[derive(Debug, Parser)] pub enum BankCommand { Get { From 94e2a2942cd9b136090fca71f9e3996eb96c6f01 Mon Sep 17 00:00:00 2001 From: jgur-psyops Date: Mon, 28 Oct 2024 19:25:04 -0400 Subject: [PATCH 34/52] Begin permissionless add pool buildout. Removing the sol_ex_rate cache TBD as part of this process --- programs/marginfi/src/constants.rs | 3 + .../marginfi_group/add_pool_permissionless.rs | 88 +++++++++++++++---- .../marginfi_group/edit_stake_settings.rs | 1 + .../marginfi_group/init_staked_settings.rs | 1 + programs/marginfi/src/state/marginfi_group.rs | 7 +- programs/marginfi/src/state/mod.rs | 1 + programs/marginfi/src/state/price.rs | 30 ++++++- .../marginfi/src/state/staked_settings.rs | 49 +++++++++++ tests/s05_solAppreciates.spec.ts | 4 + 9 files changed, 161 insertions(+), 23 deletions(-) create mode 100644 programs/marginfi/src/instructions/marginfi_group/edit_stake_settings.rs create mode 100644 programs/marginfi/src/instructions/marginfi_group/init_staked_settings.rs create mode 100644 programs/marginfi/src/state/staked_settings.rs diff --git a/programs/marginfi/src/constants.rs b/programs/marginfi/src/constants.rs index 1c96e684a..b137a3cc3 100644 --- a/programs/marginfi/src/constants.rs +++ b/programs/marginfi/src/constants.rs @@ -13,6 +13,7 @@ pub const INSURANCE_VAULT_SEED: &str = "insurance_vault"; pub const FEE_VAULT_SEED: &str = "fee_vault"; pub const FEE_STATE_SEED: &str = "feestate"; +pub const STAKED_SETTINGS_SEED: &str = "staked_settings"; pub const EMISSIONS_AUTH_SEED: &str = "emissions_auth_seed"; pub const EMISSIONS_TOKEN_ACCOUNT_SEED: &str = "emissions_token_account_seed"; @@ -47,6 +48,8 @@ cfg_if::cfg_if! { } } +pub const NATIVE_STAKE_ID: Pubkey = pubkey!("Stake11111111111111111111111111111111111111"); + /// TODO: Make these variable per bank pub const LIQUIDATION_LIQUIDATOR_FEE: I80F48 = I80F48!(0.025); pub const LIQUIDATION_INSURANCE_FEE: I80F48 = I80F48!(0.025); diff --git a/programs/marginfi/src/instructions/marginfi_group/add_pool_permissionless.rs b/programs/marginfi/src/instructions/marginfi_group/add_pool_permissionless.rs index 2fa4dfab4..63a65f08d 100644 --- a/programs/marginfi/src/instructions/marginfi_group/add_pool_permissionless.rs +++ b/programs/marginfi/src/instructions/marginfi_group/add_pool_permissionless.rs @@ -11,24 +11,26 @@ // TODO pick a hardcoded max oracle age (~30s?) -// TODO pick a hardcoded initial deposit limit () +// TODO pick a hardcoded initial deposit limit () // // TODO should the group admin need to opt in to this functionality (configure the group)? We could // also configure the key that assumes default admin here instead of using the group's admin use crate::{ + check, constants::{ ASSET_TAG_STAKED, FEE_VAULT_AUTHORITY_SEED, FEE_VAULT_SEED, INSURANCE_VAULT_AUTHORITY_SEED, INSURANCE_VAULT_SEED, LIQUIDITY_VAULT_AUTHORITY_SEED, LIQUIDITY_VAULT_SEED, + NATIVE_STAKE_ID, SPL_SINGLE_POOL_ID, STAKED_SETTINGS_SEED, }, events::{GroupEventHeader, LendingPoolBankCreateEvent}, state::{ marginfi_group::{ Bank, BankConfigCompact, BankOperationalState, InterestRateConfig, MarginfiGroup, - RiskTier, }, price::OracleSetup, + staked_settings::StakedSettings, }, - MarginfiResult, + MarginfiError, MarginfiResult, }; use anchor_lang::prelude::*; use anchor_spl::token_interface::*; @@ -48,6 +50,7 @@ pub fn lending_pool_add_bank_permissionless( } = ctx.accounts; let mut bank = bank_loader.load_init()?; + let settings = ctx.accounts.staked_settings.load()?; let group = ctx.accounts.marginfi_group.load()?; let liquidity_vault_bump = ctx.bumps.liquidity_vault; @@ -57,31 +60,27 @@ pub fn lending_pool_add_bank_permissionless( let fee_vault_bump = ctx.bumps.fee_vault; let fee_vault_authority_bump = ctx.bumps.fee_vault_authority; + // These are placeholder values: staked collateral positions do not support borrowing. let default_ir_config = InterestRateConfig { - optimal_utilization_rate: I80F48!(0.4).into(), - plateau_interest_rate: I80F48!(0.4).into(), - protocol_fixed_fee_apr: I80F48!(0.01).into(), - max_interest_rate: I80F48!(3).into(), - insurance_ir_fee: I80F48!(0.1).into(), ..Default::default() }; let default_config: BankConfigCompact = BankConfigCompact { - asset_weight_init: I80F48!(0.5).into(), - asset_weight_maint: I80F48!(0.75).into(), - liability_weight_init: I80F48!(1.5).into(), - liability_weight_maint: I80F48!(1.25).into(), - deposit_limit: 42, - interest_rate_config: default_ir_config.into(), + asset_weight_init: settings.asset_weight_init, + asset_weight_maint: settings.asset_weight_maint, + liability_weight_init: I80F48!(1.5).into(), // placeholder + liability_weight_maint: I80F48!(1.25).into(), // placeholder + deposit_limit: settings.deposit_limit, + interest_rate_config: default_ir_config.into(), // placeholder operational_state: BankOperationalState::Operational, - oracle_setup: OracleSetup::PythLegacy, - oracle_key: Pubkey::new_unique(), + oracle_setup: OracleSetup::StakedWithPythPush, + oracle_key: settings.oracle, borrow_limit: 0, - risk_tier: RiskTier::Collateral, + risk_tier: settings.risk_tier, asset_tag: ASSET_TAG_STAKED, _pad0: [0; 6], - total_asset_value_init_limit: 42, - oracle_max_age: 10, + total_asset_value_init_limit: settings.total_asset_value_init_limit, + oracle_max_age: settings.oracle_max_age, }; *bank = Bank::new( @@ -101,6 +100,36 @@ pub fn lending_pool_add_bank_permissionless( fee_vault_authority_bump, ); + { + let program_id = &SPL_SINGLE_POOL_ID; + let mint_actual = bank_mint.key(); + let stake_pool_bytes = &ctx.accounts.stake_pool.key().to_bytes(); + // Validate the given stake_pool derives the same lst_mint, proving stake_pool is correct + let (exp_mint, _) = Pubkey::find_program_address(&[b"mint", stake_pool_bytes], program_id); + check!( + exp_mint == mint_actual, + MarginfiError::StakePoolValidationFailed + ); + // Validate the now-proven stake_pool derives the given sol_pool + let (exp_pool, _) = Pubkey::find_program_address(&[b"stake", stake_pool_bytes], program_id); + check!( + exp_pool == ctx.accounts.sol_pool.key(), + MarginfiError::StakePoolValidationFailed + ); + // Sanity check these accounts exist and have the correct owning program + check!( + ctx.accounts.stake_pool.owner == &NATIVE_STAKE_ID, + MarginfiError::StakePoolValidationFailed + ); + check!( + ctx.accounts.sol_pool.owner == program_id, + MarginfiError::StakePoolValidationFailed + ); + + bank.config.oracle_keys[1] = mint_actual.key(); + bank.config.oracle_keys[2] = ctx.accounts.stake_pool.key(); + } + bank.config.validate()?; bank.config.validate_oracle_setup(ctx.remaining_accounts)?; @@ -121,11 +150,32 @@ pub fn lending_pool_add_bank_permissionless( pub struct LendingPoolAddBankPermissionless<'info> { pub marginfi_group: AccountLoader<'info, MarginfiGroup>, + #[account( + has_one = marginfi_group + )] + pub staked_settings: AccountLoader<'info, StakedSettings>, + #[account(mut)] pub fee_payer: Signer<'info>, + /// Mint of the spl-single-pool LST (a PDA derived from `stake_pool`) + /// + /// TODO test the below assumption + /// CHECK: passing a mint here that is not actually a staked collateral LST is not possible + /// because the sol_pool and stake_pool will not derive to a valid PDA which is also owned by + /// the staking program and spl-single-pool program. pub bank_mint: Box>, + /// CHECK: Validated using `stake_pool` + pub sol_pool: AccountInfo<'info>, + + /// CHECK: We validate this is correct backwards, by deriving the PDA of the `bank_mint` using + /// this key. + /// + /// If derives the same `bank_mint`, then this must be the correct stake pool for that mint, and + /// we can subsequently use it to validate the `sol_pool` + pub stake_pool: AccountInfo<'info>, + #[account( init, space = 8 + std::mem::size_of::(), diff --git a/programs/marginfi/src/instructions/marginfi_group/edit_stake_settings.rs b/programs/marginfi/src/instructions/marginfi_group/edit_stake_settings.rs new file mode 100644 index 000000000..70b786d12 --- /dev/null +++ b/programs/marginfi/src/instructions/marginfi_group/edit_stake_settings.rs @@ -0,0 +1 @@ +// TODO diff --git a/programs/marginfi/src/instructions/marginfi_group/init_staked_settings.rs b/programs/marginfi/src/instructions/marginfi_group/init_staked_settings.rs new file mode 100644 index 000000000..0ffdd02fc --- /dev/null +++ b/programs/marginfi/src/instructions/marginfi_group/init_staked_settings.rs @@ -0,0 +1 @@ +// TODO \ No newline at end of file diff --git a/programs/marginfi/src/state/marginfi_group.rs b/programs/marginfi/src/state/marginfi_group.rs index bb3835044..986be4df0 100644 --- a/programs/marginfi/src/state/marginfi_group.rs +++ b/programs/marginfi/src/state/marginfi_group.rs @@ -1167,16 +1167,19 @@ impl Display for BankOperationalState { #[repr(u8)] #[derive(Copy, Clone, Debug, AnchorSerialize, AnchorDeserialize, PartialEq, Eq)] pub enum RiskTier { - Collateral, + Collateral = 0, /// ## Isolated Risk /// Assets in this trance can be borrowed only in isolation. /// They can't be borrowed together with other assets. /// /// For example, if users has USDC, and wants to borrow XYZ which is isolated, /// they can't borrow XYZ together with SOL, only XYZ alone. - Isolated, + Isolated = 1, } +unsafe impl Zeroable for RiskTier {} +unsafe impl Pod for RiskTier {} + #[repr(C)] #[cfg_attr( any(feature = "test", feature = "client"), diff --git a/programs/marginfi/src/state/mod.rs b/programs/marginfi/src/state/mod.rs index 7b5dec9e2..1fa883b0f 100644 --- a/programs/marginfi/src/state/mod.rs +++ b/programs/marginfi/src/state/mod.rs @@ -2,3 +2,4 @@ pub mod fee_state; pub mod marginfi_account; pub mod marginfi_group; pub mod price; +pub mod staked_settings; diff --git a/programs/marginfi/src/state/price.rs b/programs/marginfi/src/state/price.rs index 83ccb1c16..344305699 100644 --- a/programs/marginfi/src/state/price.rs +++ b/programs/marginfi/src/state/price.rs @@ -5,7 +5,7 @@ use enum_dispatch::enum_dispatch; use fixed::types::I80F48; use pyth_sdk_solana::{state::SolanaPriceAccount, Price, PriceFeed}; use pyth_solana_receiver_sdk::price_update::{self, FeedId, PriceUpdateV2}; -use switchboard_on_demand::{CurrentResult, PullFeedAccountData}; +use switchboard_on_demand::{CurrentResult, PullFeedAccountData, SPL_TOKEN_PROGRAM_ID}; use switchboard_solana::{ AggregatorAccountData, AggregatorResolutionMode, SwitchboardDecimal, SWITCHBOARD_PROGRAM_ID, }; @@ -16,7 +16,8 @@ use crate::{ check, constants::{ CONF_INTERVAL_MULTIPLE, EXP_10, EXP_10_I80F48, MAX_CONF_INTERVAL, - MIN_PYTH_PUSH_VERIFICATION_LEVEL, PYTH_ID, STD_DEV_MULTIPLE, SWITCHBOARD_PULL_ID, + MIN_PYTH_PUSH_VERIFICATION_LEVEL, PYTH_ID, SPL_SINGLE_POOL_ID, STD_DEV_MULTIPLE, + SWITCHBOARD_PULL_ID, }, debug, math_error, prelude::*, @@ -35,6 +36,7 @@ pub enum OracleSetup { SwitchboardV2, PythPushOracle, SwitchboardPull, + StakedWithPythPush, } #[derive(Copy, Clone, Debug)] @@ -148,6 +150,9 @@ impl OraclePriceFeedAdapter { SwitchboardPullPriceFeed::load_checked(&ais[0], clock.unix_timestamp, max_age)?, )) } + OracleSetup::StakedWithPythPush => { + panic!("todo"); + } } } @@ -198,6 +203,27 @@ impl OraclePriceFeedAdapter { SwitchboardPullPriceFeed::check_ais(&oracle_ais[0])?; + Ok(()) + } + OracleSetup::StakedWithPythPush => { + check!(oracle_ais.len() == 3, MarginfiError::InvalidOracleAccount); + // TODO either mock this for localnet, or allow localnet to use Legacy + PythPushOraclePriceFeed::check_ai_and_feed_id( + &oracle_ais[0], + bank_config.get_pyth_push_oracle_feed_id().unwrap(), + )?; + // The spl token mint (to obtain supply information) + // Note: spl-single-pool uses a classic Token, never Token22 + check!( + oracle_ais[1].owner == &SPL_TOKEN_PROGRAM_ID, + MarginfiError::StakePoolValidationFailed + ); + // The spl stake pool (to obtain the balance of staked SOL) + check!( + oracle_ais[2].owner == &SPL_SINGLE_POOL_ID, + MarginfiError::StakePoolValidationFailed + ); + Ok(()) } } diff --git a/programs/marginfi/src/state/staked_settings.rs b/programs/marginfi/src/state/staked_settings.rs new file mode 100644 index 000000000..cf832c090 --- /dev/null +++ b/programs/marginfi/src/state/staked_settings.rs @@ -0,0 +1,49 @@ +use anchor_lang::prelude::*; + +use crate::{assert_struct_align, assert_struct_size}; + +use super::marginfi_group::{RiskTier, WrappedI80F48}; + +assert_struct_size!(StakedSettings, 256); +assert_struct_align!(StakedSettings, 8); + +/// Unique per-group. Staked Collateral banks created under a group automatically use these +/// settings. Groups that have not created this struct cannot use staked collateral positions. When +/// this struct updates, changes must be permissionlessly propogated to staked collateral banks. +/// Administrators can also edit the bank manually, i.e. with configure_bank, to temporarily make +/// changes such as raising the deposit limit for a single bank. +#[account(zero_copy)] +#[repr(C)] +pub struct StakedSettings { + /// This account's own key. A PDA derived from `marginfi_group` and `b"staked_settings"` + pub key: Pubkey, + /// Group for which these settings apply + pub marginfi_group: Pubkey, + /// Generally, the Pyth push oracle for SOL + pub oracle: Pubkey, + + pub asset_weight_init: WrappedI80F48, + pub asset_weight_maint: WrappedI80F48, + + pub deposit_limit: u64, + pub total_asset_value_init_limit: u64, + + pub oracle_max_age: u16, + pub risk_tier: RiskTier, + _pad0: [u8; 5], + + /// The following values are irrelevant because staked collateral positions do not support + /// borrowing. + // * interest_config, + // * liability_weight_init + // * liability_weight_maint + // * borrow_limit + + _reserved0: [u8; 8], + _reserved1: [u8; 32], + _reserved2: [u8; 64], +} + +impl StakedSettings { + pub const LEN: usize = std::mem::size_of::(); +} diff --git a/tests/s05_solAppreciates.spec.ts b/tests/s05_solAppreciates.spec.ts index d7cd4ab10..da1d2fdb2 100644 --- a/tests/s05_solAppreciates.spec.ts +++ b/tests/s05_solAppreciates.spec.ts @@ -132,6 +132,10 @@ describe("Borrow power grows as v0 Staked SOL gains value from appreciation", () await banksClient.processTransaction(tx); }); + // Note: in rare instances the test will run too quickly and will fail with `This transaction has + // already been processed` because it is the same tx as the previous one (i.e. if they are signed + // for the same blockhash and end up in the same slot). You can add a small delay or simply rerun + // the test. it("(permissionless) validator 0 cache stake - 1 LST is now worth 2 SOL", async () => { let tx = new Transaction().add( await cacheSolExchangeRate(program, { From 414f18adaf1843a6b37a81bf0f020ef8ed4c2f06 Mon Sep 17 00:00:00 2001 From: jgur-psyops Date: Tue, 29 Oct 2024 15:30:39 -0400 Subject: [PATCH 35/52] TODO for propagate --- .../src/instructions/marginfi_group/propagate_staked_settings.rs | 1 + 1 file changed, 1 insertion(+) create mode 100644 programs/marginfi/src/instructions/marginfi_group/propagate_staked_settings.rs diff --git a/programs/marginfi/src/instructions/marginfi_group/propagate_staked_settings.rs b/programs/marginfi/src/instructions/marginfi_group/propagate_staked_settings.rs new file mode 100644 index 000000000..0ffdd02fc --- /dev/null +++ b/programs/marginfi/src/instructions/marginfi_group/propagate_staked_settings.rs @@ -0,0 +1 @@ +// TODO \ No newline at end of file From 16c98f5e1df1ab64f592b6e4b323c39863dcbb06 Mon Sep 17 00:00:00 2001 From: jgur-psyops Date: Tue, 29 Oct 2024 17:04:54 -0400 Subject: [PATCH 36/52] Boilerplate to init staked banks with group-level default settings --- .../marginfi_group/add_pool_permissionless.rs | 19 +---- .../marginfi_group/edit_stake_settings.rs | 64 ++++++++++++++++- .../marginfi_group/init_staked_settings.rs | 72 ++++++++++++++++++- .../src/instructions/marginfi_group/mod.rs | 6 ++ .../propagate_staked_settings.rs | 37 +++++++++- programs/marginfi/src/lib.rs | 24 +++++++ programs/marginfi/src/state/marginfi_group.rs | 5 ++ .../marginfi/src/state/staked_settings.rs | 55 ++++++++++++-- 8 files changed, 258 insertions(+), 24 deletions(-) diff --git a/programs/marginfi/src/instructions/marginfi_group/add_pool_permissionless.rs b/programs/marginfi/src/instructions/marginfi_group/add_pool_permissionless.rs index 63a65f08d..9400330bb 100644 --- a/programs/marginfi/src/instructions/marginfi_group/add_pool_permissionless.rs +++ b/programs/marginfi/src/instructions/marginfi_group/add_pool_permissionless.rs @@ -1,26 +1,11 @@ // Adds a ASSET_TAG_STAKED type bank to a group with sane defaults. Used by validators to add their -// freshly-minted LST to a group so users can borrow SOL against it - -// TODO should we support this for riskTier::Isolated too? - -// TODO pick a hardcoded oracle - -// TODO pick a hardcoded interest regmine - -// TODO pick a hardcoded asset weight (~85%?) and `total_asset_value_init_limit` - -// TODO pick a hardcoded max oracle age (~30s?) - -// TODO pick a hardcoded initial deposit limit () // - -// TODO should the group admin need to opt in to this functionality (configure the group)? We could -// also configure the key that assumes default admin here instead of using the group's admin +// stake pool to a group so users can borrow SOL against it use crate::{ check, constants::{ ASSET_TAG_STAKED, FEE_VAULT_AUTHORITY_SEED, FEE_VAULT_SEED, INSURANCE_VAULT_AUTHORITY_SEED, INSURANCE_VAULT_SEED, LIQUIDITY_VAULT_AUTHORITY_SEED, LIQUIDITY_VAULT_SEED, - NATIVE_STAKE_ID, SPL_SINGLE_POOL_ID, STAKED_SETTINGS_SEED, + NATIVE_STAKE_ID, SPL_SINGLE_POOL_ID, }, events::{GroupEventHeader, LendingPoolBankCreateEvent}, state::{ diff --git a/programs/marginfi/src/instructions/marginfi_group/edit_stake_settings.rs b/programs/marginfi/src/instructions/marginfi_group/edit_stake_settings.rs index 70b786d12..cc6ccee03 100644 --- a/programs/marginfi/src/instructions/marginfi_group/edit_stake_settings.rs +++ b/programs/marginfi/src/instructions/marginfi_group/edit_stake_settings.rs @@ -1 +1,63 @@ -// TODO +// Used by the group admin to edit the default features of staked collateral banks. Remember to +// propagate afterwards. +use crate::state::marginfi_group::{RiskTier, WrappedI80F48}; +use crate::state::staked_settings::StakedSettings; +use crate::{set_if_some, MarginfiGroup}; +use anchor_lang::prelude::*; + +pub fn edit_staked_settings( + ctx: Context, + settings: StakedSettingsEditConfig, +) -> Result<()> { + let mut staked_settings = ctx.accounts.staked_settings.load_mut()?; + + set_if_some!(staked_settings.oracle, settings.oracle); + set_if_some!( + staked_settings.asset_weight_init, + settings.asset_weight_init + ); + set_if_some!( + staked_settings.asset_weight_maint, + settings.asset_weight_maint + ); + set_if_some!(staked_settings.deposit_limit, settings.deposit_limit); + set_if_some!( + staked_settings.total_asset_value_init_limit, + settings.total_asset_value_init_limit + ); + set_if_some!(staked_settings.oracle_max_age, settings.oracle_max_age); + set_if_some!(staked_settings.risk_tier, settings.risk_tier); + + Ok(()) +} + +#[derive(Accounts)] +pub struct EditStakedSettings<'info> { + #[account( + has_one = admin + )] + pub marginfi_group: AccountLoader<'info, MarginfiGroup>, + + #[account(mut)] + pub admin: Signer<'info>, + + #[account( + mut, + has_one = marginfi_group + )] + pub staked_settings: AccountLoader<'info, StakedSettings>, +} + +#[derive(AnchorDeserialize, AnchorSerialize, Default)] +pub struct StakedSettingsEditConfig { + pub oracle: Option, + + pub asset_weight_init: Option, + pub asset_weight_maint: Option, + + pub deposit_limit: Option, + pub total_asset_value_init_limit: Option, + + pub oracle_max_age: Option, + pub risk_tier: Option, +} diff --git a/programs/marginfi/src/instructions/marginfi_group/init_staked_settings.rs b/programs/marginfi/src/instructions/marginfi_group/init_staked_settings.rs index 0ffdd02fc..77903eff8 100644 --- a/programs/marginfi/src/instructions/marginfi_group/init_staked_settings.rs +++ b/programs/marginfi/src/instructions/marginfi_group/init_staked_settings.rs @@ -1 +1,71 @@ -// TODO \ No newline at end of file +// Used by the group admin to enable staked collateral banks and configure their default features +use crate::constants::STAKED_SETTINGS_SEED; +use crate::state::marginfi_group::{RiskTier, WrappedI80F48}; +use crate::state::staked_settings::StakedSettings; +use crate::MarginfiGroup; +use anchor_lang::prelude::*; + +pub fn initialize_staked_settings( + ctx: Context, + settings: StakedSettingsConfig, +) -> Result<()> { + let mut staked_settings = ctx.accounts.staked_settings.load_init()?; + + *staked_settings = StakedSettings::new( + ctx.accounts.staked_settings.key(), + ctx.accounts.marginfi_group.key(), + settings.oracle, + settings.asset_weight_init, + settings.asset_weight_maint, + settings.deposit_limit, + settings.total_asset_value_init_limit, + settings.oracle_max_age, + settings.risk_tier, + ); + + Ok(()) +} + +#[derive(Accounts)] +pub struct InitStakedSettings<'info> { + #[account( + has_one = admin + )] + pub marginfi_group: AccountLoader<'info, MarginfiGroup>, + + #[account(mut)] + pub admin: Signer<'info>, + + /// Pays the init fee + #[account(mut)] + pub fee_payer: Signer<'info>, + + #[account( + init, + seeds = [ + marginfi_group.key().as_ref(), + STAKED_SETTINGS_SEED.as_bytes() + ], + bump, + payer = fee_payer, + space = 8 + StakedSettings::LEN, + )] + pub staked_settings: AccountLoader<'info, StakedSettings>, + + pub rent: Sysvar<'info, Rent>, + pub system_program: Program<'info, System>, +} + +#[derive(AnchorDeserialize, AnchorSerialize, Default)] +pub struct StakedSettingsConfig { + pub oracle: Pubkey, + + pub asset_weight_init: WrappedI80F48, + pub asset_weight_maint: WrappedI80F48, + + pub deposit_limit: u64, + pub total_asset_value_init_limit: u64, + + pub oracle_max_age: u16, + pub risk_tier: RiskTier, +} diff --git a/programs/marginfi/src/instructions/marginfi_group/mod.rs b/programs/marginfi/src/instructions/marginfi_group/mod.rs index 713162374..f53087c4f 100644 --- a/programs/marginfi/src/instructions/marginfi_group/mod.rs +++ b/programs/marginfi/src/instructions/marginfi_group/mod.rs @@ -8,10 +8,13 @@ mod config_group_fee; mod configure; mod configure_bank; mod edit_global_fee; +mod edit_stake_settings; mod handle_bankruptcy; mod init_global_fee_state; +mod init_staked_settings; mod initialize; mod propagate_fee_state; +mod propagate_staked_settings; pub use accrue_bank_interest::*; pub use add_pool::*; @@ -23,7 +26,10 @@ pub use config_group_fee::*; pub use configure::*; pub use configure_bank::*; pub use edit_global_fee::*; +pub use edit_stake_settings::*; pub use handle_bankruptcy::*; pub use init_global_fee_state::*; +pub use init_staked_settings::*; pub use initialize::*; pub use propagate_fee_state::*; +pub use propagate_staked_settings::*; diff --git a/programs/marginfi/src/instructions/marginfi_group/propagate_staked_settings.rs b/programs/marginfi/src/instructions/marginfi_group/propagate_staked_settings.rs index 0ffdd02fc..0ae28f26f 100644 --- a/programs/marginfi/src/instructions/marginfi_group/propagate_staked_settings.rs +++ b/programs/marginfi/src/instructions/marginfi_group/propagate_staked_settings.rs @@ -1 +1,36 @@ -// TODO \ No newline at end of file +// Permissionless ix to propagate a group's staked collateral settings to any bank in that group +use crate::state::marginfi_group::Bank; +use crate::state::staked_settings::StakedSettings; +use crate::MarginfiGroup; +use anchor_lang::prelude::*; + +pub fn propagate_staked_settings(ctx: Context) -> Result<()> { + let settings = ctx.accounts.staked_settings.load()?; + let mut bank = ctx.accounts.bank.load_mut()?; + + bank.config.oracle_keys[0] = settings.oracle; + bank.config.asset_weight_init = settings.asset_weight_init; + bank.config.asset_weight_maint = settings.asset_weight_maint; + bank.config.deposit_limit = settings.deposit_limit; + bank.config.total_asset_value_init_limit = settings.total_asset_value_init_limit; + bank.config.oracle_max_age = settings.oracle_max_age; + bank.config.risk_tier = settings.risk_tier; + + Ok(()) +} + +#[derive(Accounts)] +pub struct PropagateStakedSettings<'info> { + pub marginfi_group: AccountLoader<'info, MarginfiGroup>, + + #[account( + has_one = marginfi_group + )] + pub staked_settings: AccountLoader<'info, StakedSettings>, + + #[account( + mut, + constraint = bank.load() ?.group == marginfi_group.key(), + )] + pub bank: AccountLoader<'info, Bank>, +} diff --git a/programs/marginfi/src/lib.rs b/programs/marginfi/src/lib.rs index 72733f1ea..a01497280 100644 --- a/programs/marginfi/src/lib.rs +++ b/programs/marginfi/src/lib.rs @@ -271,6 +271,30 @@ pub mod marginfi { pub fn config_group_fee(ctx: Context, flag: u64) -> MarginfiResult { marginfi_group::config_group_fee(ctx, flag) } + + /// (group admin only) Init the Staked Settings account, which is used to create staked + /// collateral banks, and must run before any staked collateral bank can be created with + /// `add_pool_permissionless`. Running this ix effectively opts the group into the staked + /// collateral feature. + pub fn init_staked_settings( + ctx: Context, + settings: StakedSettingsConfig, + ) -> MarginfiResult { + marginfi_group::initialize_staked_settings(ctx, settings) + } + + pub fn edit_staked_settings( + ctx: Context, + settings: StakedSettingsEditConfig, + ) -> MarginfiResult { + marginfi_group::edit_staked_settings(ctx, settings) + } + + pub fn propagate_staked_settings( + ctx: Context, + ) -> MarginfiResult { + marginfi_group::propagate_staked_settings(ctx) + } } #[cfg(not(feature = "no-entrypoint"))] diff --git a/programs/marginfi/src/state/marginfi_group.rs b/programs/marginfi/src/state/marginfi_group.rs index 986be4df0..e591cb598 100644 --- a/programs/marginfi/src/state/marginfi_group.rs +++ b/programs/marginfi/src/state/marginfi_group.rs @@ -1179,6 +1179,11 @@ pub enum RiskTier { unsafe impl Zeroable for RiskTier {} unsafe impl Pod for RiskTier {} +impl Default for RiskTier { + fn default() -> Self { + RiskTier::Collateral + } +} #[repr(C)] #[cfg_attr( diff --git a/programs/marginfi/src/state/staked_settings.rs b/programs/marginfi/src/state/staked_settings.rs index cf832c090..adfc8e032 100644 --- a/programs/marginfi/src/state/staked_settings.rs +++ b/programs/marginfi/src/state/staked_settings.rs @@ -1,4 +1,5 @@ use anchor_lang::prelude::*; +use fixed_macro::types::I80F48; use crate::{assert_struct_align, assert_struct_size}; @@ -8,14 +9,14 @@ assert_struct_size!(StakedSettings, 256); assert_struct_align!(StakedSettings, 8); /// Unique per-group. Staked Collateral banks created under a group automatically use these -/// settings. Groups that have not created this struct cannot use staked collateral positions. When +/// settings. Groups that have not created this struct cannot create staked collateral banks. When /// this struct updates, changes must be permissionlessly propogated to staked collateral banks. /// Administrators can also edit the bank manually, i.e. with configure_bank, to temporarily make /// changes such as raising the deposit limit for a single bank. #[account(zero_copy)] #[repr(C)] pub struct StakedSettings { - /// This account's own key. A PDA derived from `marginfi_group` and `b"staked_settings"` + /// This account's own key. A PDA derived from `marginfi_group` and `STAKED_SETTINGS_SEED` pub key: Pubkey, /// Group for which these settings apply pub marginfi_group: Pubkey, @@ -36,9 +37,8 @@ pub struct StakedSettings { /// borrowing. // * interest_config, // * liability_weight_init - // * liability_weight_maint + // * liability_weight_maint // * borrow_limit - _reserved0: [u8; 8], _reserved1: [u8; 32], _reserved2: [u8; 64], @@ -47,3 +47,50 @@ pub struct StakedSettings { impl StakedSettings { pub const LEN: usize = std::mem::size_of::(); } + +impl StakedSettings { + pub fn new( + key: Pubkey, + marginfi_group: Pubkey, + oracle: Pubkey, + asset_weight_init: WrappedI80F48, + asset_weight_maint: WrappedI80F48, + deposit_limit: u64, + total_asset_value_init_limit: u64, + oracle_max_age: u16, + risk_tier: RiskTier, + ) -> Self { + StakedSettings { + key, + marginfi_group, + oracle, + asset_weight_init, + asset_weight_maint, + deposit_limit, + total_asset_value_init_limit, + oracle_max_age, + risk_tier, + ..Default::default() + } + } +} + +impl Default for StakedSettings { + fn default() -> Self { + StakedSettings { + key: Pubkey::default(), + marginfi_group: Pubkey::default(), + oracle: Pubkey::default(), + asset_weight_init: I80F48!(0.8).into(), + asset_weight_maint: I80F48!(0.9).into(), + deposit_limit: 1_000_000, + total_asset_value_init_limit: 1_000_000, + oracle_max_age: 10, + risk_tier: RiskTier::Collateral, + _pad0: [0; 5], + _reserved0: [0; 8], + _reserved1: [0; 32], + _reserved2: [0; 64], + } + } +} From b5e0b9459959232f851f412e701aa6a0bcb62cad Mon Sep 17 00:00:00 2001 From: jgur-psyops Date: Wed, 30 Oct 2024 03:08:38 -0400 Subject: [PATCH 37/52] Boilerplate tests for init/edit satked settings --- .../marginfi_group/edit_stake_settings.rs | 7 +- .../marginfi_group/init_staked_settings.rs | 12 +- .../propagate_staked_settings.rs | 4 + programs/marginfi/src/lib.rs | 4 +- tests/01_initGroup.spec.ts | 150 +++++++++++++++++- tests/utils/group-instructions.ts | 62 +++++++- tests/utils/pdas.ts | 12 ++ tests/utils/types.ts | 105 ++++++++---- 8 files changed, 308 insertions(+), 48 deletions(-) diff --git a/programs/marginfi/src/instructions/marginfi_group/edit_stake_settings.rs b/programs/marginfi/src/instructions/marginfi_group/edit_stake_settings.rs index cc6ccee03..b4d125a58 100644 --- a/programs/marginfi/src/instructions/marginfi_group/edit_stake_settings.rs +++ b/programs/marginfi/src/instructions/marginfi_group/edit_stake_settings.rs @@ -33,12 +33,11 @@ pub fn edit_staked_settings( #[derive(Accounts)] pub struct EditStakedSettings<'info> { - #[account( - has_one = admin - )] pub marginfi_group: AccountLoader<'info, MarginfiGroup>, - #[account(mut)] + #[account( + address = marginfi_group.load()?.admin, + )] pub admin: Signer<'info>, #[account( diff --git a/programs/marginfi/src/instructions/marginfi_group/init_staked_settings.rs b/programs/marginfi/src/instructions/marginfi_group/init_staked_settings.rs index 77903eff8..ac4b851c3 100644 --- a/programs/marginfi/src/instructions/marginfi_group/init_staked_settings.rs +++ b/programs/marginfi/src/instructions/marginfi_group/init_staked_settings.rs @@ -28,12 +28,12 @@ pub fn initialize_staked_settings( #[derive(Accounts)] pub struct InitStakedSettings<'info> { - #[account( - has_one = admin - )] pub marginfi_group: AccountLoader<'info, MarginfiGroup>, - #[account(mut)] + #[account( + mut, + address = marginfi_group.load()?.admin, + )] pub admin: Signer<'info>, /// Pays the init fee @@ -43,8 +43,8 @@ pub struct InitStakedSettings<'info> { #[account( init, seeds = [ - marginfi_group.key().as_ref(), - STAKED_SETTINGS_SEED.as_bytes() + STAKED_SETTINGS_SEED.as_bytes(), + marginfi_group.key().as_ref() ], bump, payer = fee_payer, diff --git a/programs/marginfi/src/instructions/marginfi_group/propagate_staked_settings.rs b/programs/marginfi/src/instructions/marginfi_group/propagate_staked_settings.rs index 0ae28f26f..de057ab28 100644 --- a/programs/marginfi/src/instructions/marginfi_group/propagate_staked_settings.rs +++ b/programs/marginfi/src/instructions/marginfi_group/propagate_staked_settings.rs @@ -16,6 +16,10 @@ pub fn propagate_staked_settings(ctx: Context) -> Resul bank.config.oracle_max_age = settings.oracle_max_age; bank.config.risk_tier = settings.risk_tier; + bank.config.validate()?; + + // ...Possibly validate oracle or emit event. + Ok(()) } diff --git a/programs/marginfi/src/lib.rs b/programs/marginfi/src/lib.rs index a01497280..5608e1307 100644 --- a/programs/marginfi/src/lib.rs +++ b/programs/marginfi/src/lib.rs @@ -290,9 +290,7 @@ pub mod marginfi { marginfi_group::edit_staked_settings(ctx, settings) } - pub fn propagate_staked_settings( - ctx: Context, - ) -> MarginfiResult { + pub fn propagate_staked_settings(ctx: Context) -> MarginfiResult { marginfi_group::propagate_staked_settings(ctx) } } diff --git a/tests/01_initGroup.spec.ts b/tests/01_initGroup.spec.ts index 691b119ad..7f7a58a9b 100644 --- a/tests/01_initGroup.spec.ts +++ b/tests/01_initGroup.spec.ts @@ -1,16 +1,34 @@ -import { Program, workspace } from "@coral-xyz/anchor"; -import { Transaction } from "@solana/web3.js"; -import { groupInitialize } from "./utils/group-instructions"; +import { BN, Program, workspace } from "@coral-xyz/anchor"; +import { PublicKey, Transaction } from "@solana/web3.js"; +import { + editStakedSettings, + groupInitialize, + initStakedSettings, +} from "./utils/group-instructions"; import { Marginfi } from "../target/types/marginfi"; import { + ecosystem, globalFeeWallet, groupAdmin, marginfiGroup, + oracles, PROGRAM_FEE_FIXED, PROGRAM_FEE_RATE, + users, verbose, } from "./rootHooks"; -import { assertI80F48Approx, assertKeysEqual } from "./utils/genericTests"; +import { + assertBNEqual, + assertI80F48Approx, + assertKeysEqual, +} from "./utils/genericTests"; +import { assert } from "chai"; +import { bigNumberToWrappedI80F48 } from "@mrgnlabs/mrgn-common"; +import { deriveStakedSettings } from "./utils/pdas"; +import { + defaultStakedInterestSettings, + StakedSettingsEdit, +} from "./utils/types"; describe("Init group", () => { const program = workspace.Marginfi as Program; @@ -44,4 +62,128 @@ describe("Init group", () => { assertI80F48Approx(feeCache.programFeeRate, PROGRAM_FEE_RATE, tolerance); assertKeysEqual(feeCache.globalFeeWallet, globalFeeWallet); }); + + it("(attacker) Tries to init staked settings - should fail", async () => { + const settings = defaultStakedInterestSettings( + oracles.wsolOracle.publicKey + ); + let failed = false; + try { + await users[0].userMarginProgram.provider.sendAndConfirm( + new Transaction().add( + await initStakedSettings(program, { + group: marginfiGroup.publicKey, + admin: groupAdmin.wallet.publicKey, + feePayer: groupAdmin.wallet.publicKey, + settings: settings, + }) + ) + ); + } catch (err) { + // generic signature error + failed = true; + } + + assert.ok(failed, "Transaction succeeded when it should have failed"); + }); + + it("(admin) Init staked settings for group - opts in to use staked collateral", async () => { + const settings = defaultStakedInterestSettings( + oracles.wsolOracle.publicKey + ); + await groupAdmin.userMarginProgram.provider.sendAndConfirm( + new Transaction().add( + await initStakedSettings(program, { + group: marginfiGroup.publicKey, + admin: groupAdmin.wallet.publicKey, + feePayer: groupAdmin.wallet.publicKey, + settings: settings, + }) + ) + ); + + const [settingsKey] = deriveStakedSettings( + program.programId, + marginfiGroup.publicKey + ); + if (verbose) { + console.log("*init staked settings: " + settingsKey); + } + + let settingsAcc = await program.account.stakedSettings.fetch(settingsKey); + assertKeysEqual(settingsAcc.key, settingsKey); + assertKeysEqual(settingsAcc.oracle, oracles.wsolOracle.publicKey); + assertI80F48Approx(settingsAcc.assetWeightInit, 0.8); + assertI80F48Approx(settingsAcc.assetWeightMaint, 0.9); + assertBNEqual(settingsAcc.depositLimit, 1_000_000_000_000); + assertBNEqual(settingsAcc.totalAssetValueInitLimit, 150_000_000); + assert.equal(settingsAcc.oracleMaxAge, 10); + assert.deepEqual(settingsAcc.riskTier, { collateral: {} }); + }); + + it("(attacker) Tries to edit staked settings - should fail", async () => { + // TODO + // const settings = defaultStakedInterestSettings( + // oracles.wsolOracle.publicKey + // ); + // let failed = false; + // try { + // await users[0].userMarginProgram.provider.sendAndConfirm( + // new Transaction().add( + // await initStakedSettings(program, { + // group: marginfiGroup.publicKey, + // admin: groupAdmin.wallet.publicKey, + // feePayer: groupAdmin.wallet.publicKey, + // settings: settings, + // }) + // ) + // ); + // } catch (err) { + // // generic signature error + // failed = true; + // } + // assert.ok(failed, "Transaction succeeded when it should have failed"); + }); + + it("(admin) Edit staked settings for group", async () => { + const settings: StakedSettingsEdit = { + oracle: PublicKey.default, + assetWeightInit: bigNumberToWrappedI80F48(0.2), + assetWeightMaint: bigNumberToWrappedI80F48(0.3), + depositLimit: new BN(42), + totalAssetValueInitLimit: new BN(43), + oracleMaxAge: 44, + riskTier: { + isolated: undefined, + }, + }; + await groupAdmin.userMarginProgram.provider.sendAndConfirm( + new Transaction().add( + await editStakedSettings(program, { + group: marginfiGroup.publicKey, + admin: groupAdmin.wallet.publicKey, + feePayer: groupAdmin.wallet.publicKey, + settings: settings, + }) + ) + ); + + const [settingsKey] = deriveStakedSettings( + program.programId, + marginfiGroup.publicKey + ); + if (verbose) { + console.log("*edit staked settings: " + settingsKey); + } + + let settingsAcc = await program.account.stakedSettings.fetch(settingsKey); + assertKeysEqual(settingsAcc.key, settingsKey); + assertKeysEqual(settingsAcc.oracle, PublicKey.default); + assertI80F48Approx(settingsAcc.assetWeightInit, 0.2); + assertI80F48Approx(settingsAcc.assetWeightMaint, 0.3); + assertBNEqual(settingsAcc.depositLimit, 42); + assertBNEqual(settingsAcc.totalAssetValueInitLimit, 43); + assert.equal(settingsAcc.oracleMaxAge, 44); + assert.deepEqual(settingsAcc.riskTier, { isolated: {} }); + }); }); diff --git a/tests/utils/group-instructions.ts b/tests/utils/group-instructions.ts index 1d6f2cc90..5780678c5 100644 --- a/tests/utils/group-instructions.ts +++ b/tests/utils/group-instructions.ts @@ -8,8 +8,14 @@ import { deriveInsuranceVaultAuthority, deriveLiquidityVault, deriveLiquidityVaultAuthority, + deriveStakedSettings, } from "./pdas"; -import { BankConfig, BankConfigOptWithAssetTag } from "./types"; +import { + BankConfig, + BankConfigOptWithAssetTag, + StakedSettingsConfig, + StakedSettingsEdit, +} from "./types"; import { TOKEN_PROGRAM_ID } from "@solana/spl-token"; import { BankConfigOptRaw } from "@mrgnlabs/marginfi-client-v2"; import { WrappedI80F48 } from "@mrgnlabs/mrgn-common"; @@ -223,6 +229,8 @@ export const updateEmissions = ( return ix; }; +// ************* Below this line, not yet included in package **************** + export type CacheSolExchangeRateArgs = { bank: PublicKey; lstMint: PublicKey; @@ -308,3 +316,55 @@ export const editGlobalFeeState = ( return ix; }; + +export type InitStakedSettingsArgs = { + group: PublicKey; + admin: PublicKey; + feePayer: PublicKey; + settings: StakedSettingsConfig; +}; + +export const initStakedSettings = ( + program: Program, + args: InitStakedSettingsArgs +) => { + const ix = program.methods + .initStakedSettings(args.settings) + .accounts({ + marginfiGroup: args.group, + admin: args.admin, + feePayer: args.feePayer, + // staked_settings: deriveStakedSettings() + // rent = SYSVAR_RENT_PUBKEY, + // systemProgram: SystemProgram.programId, + }) + .instruction(); + + return ix; +}; + +export type EditStakedSettingsArgs = { + group: PublicKey; + admin: PublicKey; + feePayer: PublicKey; + settings: StakedSettingsEdit; +}; + +export const editStakedSettings = ( + program: Program, + args: EditStakedSettingsArgs +) => { + let [settingsKey] = deriveStakedSettings(program.programId, args.group); + const ix = program.methods + .editStakedSettings(args.settings) + .accounts({ + // marginfiGroup: args.group, // implied from settings + admin: args.admin, + stakedSettings: settingsKey, + // rent = SYSVAR_RENT_PUBKEY, + // systemProgram: SystemProgram.programId, + }) + .instruction(); + + return ix; +}; diff --git a/tests/utils/pdas.ts b/tests/utils/pdas.ts index 87aaa6bc8..51a392bca 100644 --- a/tests/utils/pdas.ts +++ b/tests/utils/pdas.ts @@ -81,9 +81,21 @@ export const deriveEmissionsTokenAccount = ( ); }; +// ************* Below this line, not yet included in package **************** + export const deriveGlobalFeeState = (programId: PublicKey) => { return PublicKey.findProgramAddressSync( [Buffer.from("feestate", "utf-8")], programId ); }; + +export const deriveStakedSettings = ( + programId: PublicKey, + group: PublicKey +) => { + return PublicKey.findProgramAddressSync( + [Buffer.from("staked_settings", "utf-8"), group.toBuffer()], + programId + ); +}; diff --git a/tests/utils/types.ts b/tests/utils/types.ts index bf0f674a4..bee66cb81 100644 --- a/tests/utils/types.ts +++ b/tests/utils/types.ts @@ -30,36 +30,6 @@ export const ASSET_TAG_DEFAULT = 0; export const ASSET_TAG_SOL = 1; export const ASSET_TAG_STAKED = 2; -type OperationalStateRaw = - | { paused: {} } - | { operational: {} } - | { reduceOnly: {} }; - -export type BankConfig = { - assetWeightInit: WrappedI80F48; - assetWeightMaint: WrappedI80F48; - - liabilityWeightInit: WrappedI80F48; - liabilityWeightMain: WrappedI80F48; - - depositLimit: BN; - interestRateConfig: InterestRateConfigRaw; - - /** Paused = 0, Operational = 1, ReduceOnly = 2 */ - operationalState: OperationalStateRaw; - - /** None = 0, PythLegacy = 1, SwitchboardV2 = 2, PythPushOracle =3 */ - oracleSetup: OracleSetupRaw; - oracleKey: PublicKey; - - borrowLimit: BN; - /** Collateral = 0, Isolated = 1 */ - riskTier: RiskTierRaw; - assetTag: number; - totalAssetValueInitLimit: BN; - oracleMaxAge: number; -}; - /** * The default bank config has * * all weights are 1 @@ -193,6 +163,21 @@ export const defaultInterestRateConfig = () => { return config; }; +export const defaultStakedInterestSettings = (oracle: PublicKey) => { + let settings: StakedSettingsConfig = { + oracle: oracle, + assetWeightInit: bigNumberToWrappedI80F48(0.8), + assetWeightMaint: bigNumberToWrappedI80F48(0.9), + depositLimit: new BN(1_000_000_000_000), // 1000 SOL + totalAssetValueInitLimit: new BN(150_000_000), + oracleMaxAge: 10, + riskTier: { + collateral: undefined, + }, + }; + return settings; +}; + // TODO remove when package updates export type BankConfigOptWithAssetTag = BankConfigOptRaw & { assetTag: number | null; @@ -207,3 +192,63 @@ export type InterestRateConfigRawWithOrigination = InterestRateConfigRaw & { export type InterestRateConfigWithOrigination = InterestRateConfig & { protocolOriginationFee: BigNumber; }; + +// TODO remove when package updates +type OperationalStateRaw = + | { paused: {} } + | { operational: {} } + | { reduceOnly: {} }; + +// TODO remove when package updates +export type BankConfig = { + assetWeightInit: WrappedI80F48; + assetWeightMaint: WrappedI80F48; + + liabilityWeightInit: WrappedI80F48; + liabilityWeightMain: WrappedI80F48; + + depositLimit: BN; + interestRateConfig: InterestRateConfigRaw; + + /** Paused = 0, Operational = 1, ReduceOnly = 2 */ + operationalState: OperationalStateRaw; + + /** None = 0, PythLegacy = 1, SwitchboardV2 = 2, PythPushOracle =3 */ + oracleSetup: OracleSetupRaw; + oracleKey: PublicKey; + + borrowLimit: BN; + /** Collateral = 0, Isolated = 1 */ + riskTier: RiskTierRaw; + assetTag: number; + totalAssetValueInitLimit: BN; + oracleMaxAge: number; +}; + +// TODO remove when package updates +export type StakedSettingsConfig = { + oracle: PublicKey; + + assetWeightInit: WrappedI80F48; + assetWeightMaint: WrappedI80F48; + + depositLimit: BN; + totalAssetValueInitLimit: BN; + + oracleMaxAge: number; + /** Collateral = 0, Isolated = 1 */ + riskTier: RiskTierRaw; +}; + +export interface StakedSettingsEdit { + oracle: PublicKey | null; + + assetWeightInit: WrappedI80F48 | null; + assetWeightMaint: WrappedI80F48 | null; + + depositLimit: BN | null; + totalAssetValueInitLimit: BN | null; + + oracleMaxAge: number | null; + riskTier: { collateral: {} } | { isolated: {} } | null; +} From 07d2f3de041f078d0d978f8b29fc340bbb848097 Mon Sep 17 00:00:00 2001 From: jgur-psyops Date: Wed, 30 Oct 2024 15:14:43 -0400 Subject: [PATCH 38/52] Permissionless pool init with default settings happy path test --- programs/marginfi/src/errors.rs | 2 + .../instructions/marginfi_group/add_pool.rs | 12 +- .../marginfi_group/add_pool_permissionless.rs | 20 +++- .../marginfi_group/add_pool_with_seed.rs | 16 +-- .../marginfi_group/edit_stake_settings.rs | 11 +- .../marginfi_group/init_staked_settings.rs | 10 +- .../propagate_staked_settings.rs | 7 +- programs/marginfi/src/lib.rs | 7 ++ programs/marginfi/src/state/price.rs | 38 ++++-- tests/01_initGroup.spec.ts | 113 +++++++++++++----- tests/rootHooks.ts | 4 +- tests/s01_usersStake.spec.ts | 4 +- tests/s02_addBank.spec.ts | 95 +++++++++++++-- tests/s05_solAppreciates.spec.ts | 6 +- tests/utils/genericTests.ts | 1 + tests/utils/group-instructions.ts | 86 +++++++++++-- tests/utils/mocks.ts | 3 +- tests/utils/pdas.ts | 13 ++ 18 files changed, 354 insertions(+), 94 deletions(-) diff --git a/programs/marginfi/src/errors.rs b/programs/marginfi/src/errors.rs index 55e6368c4..6a9834dae 100644 --- a/programs/marginfi/src/errors.rs +++ b/programs/marginfi/src/errors.rs @@ -102,6 +102,8 @@ pub enum MarginfiError { StakePoolValidationFailed, #[msg("Invalid ATA for global fee account")] // 6049 InvalidFeeAta, + #[msg("Use add pool permissionless instead")] // 6050 + AddedStakedPoolManually, } impl From for ProgramError { diff --git a/programs/marginfi/src/instructions/marginfi_group/add_pool.rs b/programs/marginfi/src/instructions/marginfi_group/add_pool.rs index 7b1610323..d9e7571c2 100644 --- a/programs/marginfi/src/instructions/marginfi_group/add_pool.rs +++ b/programs/marginfi/src/instructions/marginfi_group/add_pool.rs @@ -1,14 +1,16 @@ use crate::{ + check, constants::{ - FEE_STATE_SEED, FEE_VAULT_AUTHORITY_SEED, FEE_VAULT_SEED, INSURANCE_VAULT_AUTHORITY_SEED, - INSURANCE_VAULT_SEED, LIQUIDITY_VAULT_AUTHORITY_SEED, LIQUIDITY_VAULT_SEED, + ASSET_TAG_STAKED, FEE_STATE_SEED, FEE_VAULT_AUTHORITY_SEED, FEE_VAULT_SEED, + INSURANCE_VAULT_AUTHORITY_SEED, INSURANCE_VAULT_SEED, LIQUIDITY_VAULT_AUTHORITY_SEED, + LIQUIDITY_VAULT_SEED, }, events::{GroupEventHeader, LendingPoolBankCreateEvent}, state::{ fee_state::FeeState, marginfi_group::{Bank, BankConfig, BankConfigCompact, MarginfiGroup}, }, - MarginfiResult, + MarginfiError, MarginfiResult, }; use anchor_lang::prelude::*; use anchor_spl::token_interface::*; @@ -42,6 +44,10 @@ pub fn lending_pool_add_bank( } = ctx.accounts; let mut bank = bank_loader.load_init()?; + check!( + !(bank_config.asset_tag == ASSET_TAG_STAKED), + MarginfiError::AddedStakedPoolManually + ); let liquidity_vault_bump = ctx.bumps.liquidity_vault; let liquidity_vault_authority_bump = ctx.bumps.liquidity_vault_authority; diff --git a/programs/marginfi/src/instructions/marginfi_group/add_pool_permissionless.rs b/programs/marginfi/src/instructions/marginfi_group/add_pool_permissionless.rs index 9400330bb..16e26478a 100644 --- a/programs/marginfi/src/instructions/marginfi_group/add_pool_permissionless.rs +++ b/programs/marginfi/src/instructions/marginfi_group/add_pool_permissionless.rs @@ -45,8 +45,16 @@ pub fn lending_pool_add_bank_permissionless( let fee_vault_bump = ctx.bumps.fee_vault; let fee_vault_authority_bump = ctx.bumps.fee_vault_authority; - // These are placeholder values: staked collateral positions do not support borrowing. + // These are placeholder values: staked collateral positions do not support borrowing and likely + // never will, thus they will earn no interest. + + // Note: Some placeholder values are non-zero to handle downstream validation checks. let default_ir_config = InterestRateConfig { + optimal_utilization_rate: I80F48!(0.4).into(), + plateau_interest_rate: I80F48!(0.4).into(), + protocol_fixed_fee_apr: I80F48!(0.01).into(), + max_interest_rate: I80F48!(3).into(), + insurance_ir_fee: I80F48!(0.1).into(), ..Default::default() }; @@ -101,18 +109,20 @@ pub fn lending_pool_add_bank_permissionless( exp_pool == ctx.accounts.sol_pool.key(), MarginfiError::StakePoolValidationFailed ); - // Sanity check these accounts exist and have the correct owning program + // Sanity check these accounts exist and have the correct owning program. Note: Mint's owner + // is already checked by the Mint anchor decorator check!( - ctx.accounts.stake_pool.owner == &NATIVE_STAKE_ID, + ctx.accounts.stake_pool.owner == program_id, MarginfiError::StakePoolValidationFailed ); check!( - ctx.accounts.sol_pool.owner == program_id, + ctx.accounts.sol_pool.owner == &NATIVE_STAKE_ID, MarginfiError::StakePoolValidationFailed ); + // The mint (for supply) and stake pool (for sol balance) are recorded for price calculation bank.config.oracle_keys[1] = mint_actual.key(); - bank.config.oracle_keys[2] = ctx.accounts.stake_pool.key(); + bank.config.oracle_keys[2] = ctx.accounts.sol_pool.key(); } bank.config.validate()?; diff --git a/programs/marginfi/src/instructions/marginfi_group/add_pool_with_seed.rs b/programs/marginfi/src/instructions/marginfi_group/add_pool_with_seed.rs index a0bcee795..033cc6f2f 100644 --- a/programs/marginfi/src/instructions/marginfi_group/add_pool_with_seed.rs +++ b/programs/marginfi/src/instructions/marginfi_group/add_pool_with_seed.rs @@ -1,14 +1,10 @@ use crate::{ - constants::{ - FEE_STATE_SEED, FEE_VAULT_AUTHORITY_SEED, FEE_VAULT_SEED, INSURANCE_VAULT_AUTHORITY_SEED, - INSURANCE_VAULT_SEED, LIQUIDITY_VAULT_AUTHORITY_SEED, LIQUIDITY_VAULT_SEED, - }, - events::{GroupEventHeader, LendingPoolBankCreateEvent}, - state::{ + check, constants::{ + ASSET_TAG_STAKED, FEE_STATE_SEED, FEE_VAULT_AUTHORITY_SEED, FEE_VAULT_SEED, INSURANCE_VAULT_AUTHORITY_SEED, INSURANCE_VAULT_SEED, LIQUIDITY_VAULT_AUTHORITY_SEED, LIQUIDITY_VAULT_SEED + }, events::{GroupEventHeader, LendingPoolBankCreateEvent}, state::{ fee_state::FeeState, marginfi_group::{Bank, BankConfig, BankConfigCompact, MarginfiGroup}, - }, - MarginfiResult, + }, MarginfiError, MarginfiResult }; use anchor_lang::prelude::*; use anchor_spl::token_interface::*; @@ -42,6 +38,10 @@ pub fn lending_pool_add_bank_with_seed( } = ctx.accounts; let mut bank = bank_loader.load_init()?; + check!( + !(bank_config.asset_tag == ASSET_TAG_STAKED), + MarginfiError::AddedStakedPoolManually + ); let liquidity_vault_bump = ctx.bumps.liquidity_vault; let liquidity_vault_authority_bump = ctx.bumps.liquidity_vault_authority; diff --git a/programs/marginfi/src/instructions/marginfi_group/edit_stake_settings.rs b/programs/marginfi/src/instructions/marginfi_group/edit_stake_settings.rs index b4d125a58..446ca48f5 100644 --- a/programs/marginfi/src/instructions/marginfi_group/edit_stake_settings.rs +++ b/programs/marginfi/src/instructions/marginfi_group/edit_stake_settings.rs @@ -9,7 +9,9 @@ pub fn edit_staked_settings( ctx: Context, settings: StakedSettingsEditConfig, ) -> Result<()> { + // let group = ctx.accounts.marginfi_group.load()?; let mut staked_settings = ctx.accounts.staked_settings.load_mut()?; + // require_keys_eq!(group.admin, ctx.accounts.admin.key()); set_if_some!(staked_settings.oracle, settings.oracle); set_if_some!( @@ -33,11 +35,11 @@ pub fn edit_staked_settings( #[derive(Accounts)] pub struct EditStakedSettings<'info> { - pub marginfi_group: AccountLoader<'info, MarginfiGroup>, - #[account( - address = marginfi_group.load()?.admin, + has_one = admin )] + pub marginfi_group: AccountLoader<'info, MarginfiGroup>, + pub admin: Signer<'info>, #[account( @@ -58,5 +60,8 @@ pub struct StakedSettingsEditConfig { pub total_asset_value_init_limit: Option, pub oracle_max_age: Option, + /// WARN: You almost certainly want "Collateral", using Isolated risk tier makes the asset + /// worthless as collateral, making all outstanding accounts eligible to be liquidated, and is + /// generally useful only when creating a staked collateral pool for rewards purposes only. pub risk_tier: Option, } diff --git a/programs/marginfi/src/instructions/marginfi_group/init_staked_settings.rs b/programs/marginfi/src/instructions/marginfi_group/init_staked_settings.rs index ac4b851c3..27907bd85 100644 --- a/programs/marginfi/src/instructions/marginfi_group/init_staked_settings.rs +++ b/programs/marginfi/src/instructions/marginfi_group/init_staked_settings.rs @@ -28,12 +28,11 @@ pub fn initialize_staked_settings( #[derive(Accounts)] pub struct InitStakedSettings<'info> { - pub marginfi_group: AccountLoader<'info, MarginfiGroup>, - #[account( - mut, - address = marginfi_group.load()?.admin, + has_one = admin )] + pub marginfi_group: AccountLoader<'info, MarginfiGroup>, + pub admin: Signer<'info>, /// Pays the init fee @@ -67,5 +66,8 @@ pub struct StakedSettingsConfig { pub total_asset_value_init_limit: u64, pub oracle_max_age: u16, + /// WARN: You almost certainly want "Collateral", using Isolated risk tier makes the asset + /// worthless as collateral, and is generally useful only when creating a staked collateral pool + /// for rewards purposes only. pub risk_tier: RiskTier, } diff --git a/programs/marginfi/src/instructions/marginfi_group/propagate_staked_settings.rs b/programs/marginfi/src/instructions/marginfi_group/propagate_staked_settings.rs index de057ab28..1b7d4e399 100644 --- a/programs/marginfi/src/instructions/marginfi_group/propagate_staked_settings.rs +++ b/programs/marginfi/src/instructions/marginfi_group/propagate_staked_settings.rs @@ -1,3 +1,4 @@ +use crate::constants::ASSET_TAG_STAKED; // Permissionless ix to propagate a group's staked collateral settings to any bank in that group use crate::state::marginfi_group::Bank; use crate::state::staked_settings::StakedSettings; @@ -34,7 +35,11 @@ pub struct PropagateStakedSettings<'info> { #[account( mut, - constraint = bank.load() ?.group == marginfi_group.key(), + constraint = { + let bank = bank.load()?; + bank.group == marginfi_group.key() && + bank.config.asset_tag == ASSET_TAG_STAKED + } )] pub bank: AccountLoader<'info, Bank>, } diff --git a/programs/marginfi/src/lib.rs b/programs/marginfi/src/lib.rs index 5608e1307..3d466fdb1 100644 --- a/programs/marginfi/src/lib.rs +++ b/programs/marginfi/src/lib.rs @@ -58,6 +58,13 @@ pub mod marginfi { marginfi_group::lending_pool_add_bank_with_seed(ctx, bank_config.into(), bank_seed) } + pub fn lending_pool_add_bank_permissionless( + ctx: Context, + bank_seed: u64, + ) -> MarginfiResult { + marginfi_group::lending_pool_add_bank_permissionless(ctx, bank_seed) + } + pub fn lending_pool_configure_bank( ctx: Context, bank_config_opt: BankConfigOpt, diff --git a/programs/marginfi/src/state/price.rs b/programs/marginfi/src/state/price.rs index 344305699..db4e72fd1 100644 --- a/programs/marginfi/src/state/price.rs +++ b/programs/marginfi/src/state/price.rs @@ -16,7 +16,7 @@ use crate::{ check, constants::{ CONF_INTERVAL_MULTIPLE, EXP_10, EXP_10_I80F48, MAX_CONF_INTERVAL, - MIN_PYTH_PUSH_VERIFICATION_LEVEL, PYTH_ID, SPL_SINGLE_POOL_ID, STD_DEV_MULTIPLE, + MIN_PYTH_PUSH_VERIFICATION_LEVEL, NATIVE_STAKE_ID, PYTH_ID, STD_DEV_MULTIPLE, SWITCHBOARD_PULL_ID, }, debug, math_error, @@ -207,20 +207,38 @@ impl OraclePriceFeedAdapter { } OracleSetup::StakedWithPythPush => { check!(oracle_ais.len() == 3, MarginfiError::InvalidOracleAccount); - // TODO either mock this for localnet, or allow localnet to use Legacy - PythPushOraclePriceFeed::check_ai_and_feed_id( - &oracle_ais[0], - bank_config.get_pyth_push_oracle_feed_id().unwrap(), - )?; - // The spl token mint (to obtain supply information) - // Note: spl-single-pool uses a classic Token, never Token22 + // Note: mainnet/staging/devnet use push oracles, localnet uses legacy push + if cfg!(any( + feature = "mainnet-beta", + feature = "staging", + feature = "devnet" + )) { + PythPushOraclePriceFeed::check_ai_and_feed_id( + &oracle_ais[0], + bank_config.get_pyth_push_oracle_feed_id().unwrap(), + )?; + } else { + // Localnet only + check!( + oracle_ais[0].key == &bank_config.oracle_keys[0], + MarginfiError::InvalidOracleAccount + ); + + PythLegacyPriceFeed::check_ais(&oracle_ais[0])?; + } + + // Sanity checks (PDA validation for these accounts happens once, at bank initialization) + + // The spl token mint (to obtain supply information). Note: spl-single-pool uses a + // classic Token, never Token22 check!( oracle_ais[1].owner == &SPL_TOKEN_PROGRAM_ID, MarginfiError::StakePoolValidationFailed ); - // The spl stake pool (to obtain the balance of staked SOL) + // The spl stake pool (to obtain the balance of staked SOL). Note: the native + // staking program is written in vanilla Rust and has no Anchor discriminator. check!( - oracle_ais[2].owner == &SPL_SINGLE_POOL_ID, + oracle_ais[2].owner == &NATIVE_STAKE_ID, MarginfiError::StakePoolValidationFailed ); diff --git a/tests/01_initGroup.spec.ts b/tests/01_initGroup.spec.ts index 7f7a58a9b..e2db8a605 100644 --- a/tests/01_initGroup.spec.ts +++ b/tests/01_initGroup.spec.ts @@ -71,9 +71,8 @@ describe("Init group", () => { try { await users[0].userMarginProgram.provider.sendAndConfirm( new Transaction().add( - await initStakedSettings(program, { + await initStakedSettings(users[0].userMarginProgram, { group: marginfiGroup.publicKey, - admin: groupAdmin.wallet.publicKey, feePayer: groupAdmin.wallet.publicKey, settings: settings, }) @@ -93,9 +92,8 @@ describe("Init group", () => { ); await groupAdmin.userMarginProgram.provider.sendAndConfirm( new Transaction().add( - await initStakedSettings(program, { + await initStakedSettings(groupAdmin.userMarginProgram, { group: marginfiGroup.publicKey, - admin: groupAdmin.wallet.publicKey, feePayer: groupAdmin.wallet.publicKey, settings: settings, }) @@ -122,27 +120,37 @@ describe("Init group", () => { }); it("(attacker) Tries to edit staked settings - should fail", async () => { - // TODO - // const settings = defaultStakedInterestSettings( - // oracles.wsolOracle.publicKey - // ); - // let failed = false; - // try { - // await users[0].userMarginProgram.provider.sendAndConfirm( - // new Transaction().add( - // await initStakedSettings(program, { - // group: marginfiGroup.publicKey, - // admin: groupAdmin.wallet.publicKey, - // feePayer: groupAdmin.wallet.publicKey, - // settings: settings, - // }) - // ) - // ); - // } catch (err) { - // // generic signature error - // failed = true; - // } - // assert.ok(failed, "Transaction succeeded when it should have failed"); + const settings: StakedSettingsEdit = { + oracle: PublicKey.default, + assetWeightInit: bigNumberToWrappedI80F48(0.2), + assetWeightMaint: bigNumberToWrappedI80F48(0.3), + depositLimit: new BN(42), + totalAssetValueInitLimit: new BN(43), + oracleMaxAge: 44, + riskTier: { + isolated: undefined, + }, + }; + let failed = false; + try { + const [settingsKey] = deriveStakedSettings( + program.programId, + marginfiGroup.publicKey + ); + + await users[0].userMarginProgram.provider.sendAndConfirm( + new Transaction().add( + await editStakedSettings(users[0].userMarginProgram, { + settingsKey: settingsKey, + settings: settings, + }) + ) + ); + } catch (err) { + // generic signature error + failed = true; + } + assert.ok(failed, "Transaction succeeded when it should have failed"); }); it("(admin) Edit staked settings for group", async () => { @@ -157,21 +165,20 @@ describe("Init group", () => { isolated: undefined, }, }; + const [settingsKey] = deriveStakedSettings( + program.programId, + marginfiGroup.publicKey + ); + await groupAdmin.userMarginProgram.provider.sendAndConfirm( new Transaction().add( - await editStakedSettings(program, { - group: marginfiGroup.publicKey, - admin: groupAdmin.wallet.publicKey, - feePayer: groupAdmin.wallet.publicKey, + await editStakedSettings(groupAdmin.userMarginProgram, { + settingsKey: settingsKey, settings: settings, }) ) ); - const [settingsKey] = deriveStakedSettings( - program.programId, - marginfiGroup.publicKey - ); if (verbose) { console.log("*edit staked settings: " + settingsKey); } @@ -186,4 +193,44 @@ describe("Init group", () => { assert.equal(settingsAcc.oracleMaxAge, 44); assert.deepEqual(settingsAcc.riskTier, { isolated: {} }); }); + + it("(admin) Partial settings update", async () => { + const settings: StakedSettingsEdit = { + oracle: null, + assetWeightInit: null, + assetWeightMaint: null, + depositLimit: null, + totalAssetValueInitLimit: null, + oracleMaxAge: 60, + riskTier: { + isolated: undefined, + }, + }; + const [settingsKey] = deriveStakedSettings( + program.programId, + marginfiGroup.publicKey + ); + + await groupAdmin.userMarginProgram.provider.sendAndConfirm( + new Transaction().add( + await editStakedSettings(groupAdmin.userMarginProgram, { + settingsKey: settingsKey, + settings: settings, + }) + ) + ); + + let settingsAcc = await program.account.stakedSettings.fetch(settingsKey); + // No change + assertKeysEqual(settingsAcc.key, settingsKey); + assertKeysEqual(settingsAcc.oracle, PublicKey.default); + assertI80F48Approx(settingsAcc.assetWeightInit, 0.2); + assertI80F48Approx(settingsAcc.assetWeightMaint, 0.3); + assertBNEqual(settingsAcc.depositLimit, 42); + assertBNEqual(settingsAcc.totalAssetValueInitLimit, 43); + assert.deepEqual(settingsAcc.riskTier, { isolated: {} }); + + assert.equal(settingsAcc.oracleMaxAge, 60); + }); + }); diff --git a/tests/rootHooks.ts b/tests/rootHooks.ts index e69838d34..0403df93c 100644 --- a/tests/rootHooks.ts +++ b/tests/rootHooks.ts @@ -343,7 +343,7 @@ export const createValidator = async ( splPool: PublicKey.default, splMint: PublicKey.default, splAuthority: PublicKey.default, - splStake: PublicKey.default, + splSolPool: PublicKey.default, bank: PublicKey.default, }; @@ -395,7 +395,7 @@ export const createSplStakePool = async ( copyKeys.push(poolMintKey); const poolStake = await findPoolStakeAddress(SINGLE_POOL_PROGRAM_ID, poolKey); - validator.splStake = poolStake; + validator.splSolPool = poolStake; copyKeys.push(poolStake); const poolAuthority = await findPoolStakeAuthorityAddress( diff --git a/tests/s01_usersStake.spec.ts b/tests/s01_usersStake.spec.ts index 4b19386f2..931c9ce9e 100644 --- a/tests/s01_usersStake.spec.ts +++ b/tests/s01_usersStake.spec.ts @@ -225,7 +225,7 @@ describe("User stakes some native and creates an account", () => { // The spl stake pool account is already infused with 1 SOL at init const splStakeInfoBefore = await bankRunProvider.connection.getAccountInfo( - validators[0].splStake + validators[0].splSolPool ); const splStakePoolBefore = getStakeAccount(splStakeInfoBefore.data); const delegationSplPoolBefore = new BN( @@ -264,7 +264,7 @@ describe("User stakes some native and creates an account", () => { const [lstAfter, splStakePoolInfo] = await Promise.all([ getTokenBalance(bankRunProvider, lstAta), - bankRunProvider.connection.getAccountInfo(validators[0].splStake), + bankRunProvider.connection.getAccountInfo(validators[0].splSolPool), ]); if (verbose) { console.log("lst after: " + lstAfter.toLocaleString()); diff --git a/tests/s02_addBank.spec.ts b/tests/s02_addBank.spec.ts index a16676dfa..793d1936a 100644 --- a/tests/s02_addBank.spec.ts +++ b/tests/s02_addBank.spec.ts @@ -1,6 +1,11 @@ import { BN, Program, workspace } from "@coral-xyz/anchor"; import { Keypair, Transaction } from "@solana/web3.js"; -import { addBank, groupInitialize } from "./utils/group-instructions"; +import { + addBank, + addBankPermissionless, + groupInitialize, + initStakedSettings, +} from "./utils/group-instructions"; import { Marginfi } from "../target/types/marginfi"; import { bankKeypairSol, @@ -15,16 +20,25 @@ import { validators, verbose, } from "./rootHooks"; -import { assertI80F48Equal, assertKeysEqual } from "./utils/genericTests"; +import { + assertBankrunTxFailed, + assertBNEqual, + assertI80F48Approx, + assertI80F48Equal, + assertKeysEqual, +} from "./utils/genericTests"; import { ASSET_TAG_DEFAULT, ASSET_TAG_SOL, ASSET_TAG_STAKED, defaultBankConfig, + defaultStakedInterestSettings, I80F48_ONE, + SINGLE_POOL_PROGRAM_ID, } from "./utils/types"; import { assert } from "chai"; import { getBankrunBlockhash } from "./utils/spl-staking-utils"; +import { deriveBankWithSeed, deriveStakedSettings } from "./utils/pdas"; describe("Init group and add banks with asset category flags", () => { const program = workspace.Marginfi as Program; @@ -52,6 +66,46 @@ describe("Init group and add banks with asset category flags", () => { } }); + // TODO add bank permissionless fails prior to opting in + + it("(admin) Init staked settings for group - opts in to use staked collateral", async () => { + const settings = defaultStakedInterestSettings( + oracles.wsolOracle.publicKey + ); + let tx = new Transaction(); + + tx.add( + await initStakedSettings(groupAdmin.userMarginProgram, { + group: marginfiGroup.publicKey, + feePayer: groupAdmin.wallet.publicKey, + settings: settings, + }) + ); + tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + tx.sign(groupAdmin.wallet); + await banksClient.processTransaction(tx); + + const [settingsKey] = deriveStakedSettings( + program.programId, + marginfiGroup.publicKey + ); + if (verbose) { + console.log("*init staked settings: " + settingsKey); + } + + let settingsAcc = await bankrunProgram.account.stakedSettings.fetch( + settingsKey + ); + assertKeysEqual(settingsAcc.key, settingsKey); + assertKeysEqual(settingsAcc.oracle, oracles.wsolOracle.publicKey); + assertI80F48Approx(settingsAcc.assetWeightInit, 0.8); + assertI80F48Approx(settingsAcc.assetWeightMaint, 0.9); + assertBNEqual(settingsAcc.depositLimit, 1_000_000_000_000); + assertBNEqual(settingsAcc.totalAssetValueInitLimit, 150_000_000); + assert.equal(settingsAcc.oracleMaxAge, 10); + assert.deepEqual(settingsAcc.riskTier, { collateral: {} }); + }); + it("(admin) Add bank (USDC) - is neither SOL nor staked LST", async () => { let setConfig = defaultBankConfig(oracles.usdcOracle.publicKey); let bankKey = bankKeypairUsdc.publicKey; @@ -107,38 +161,59 @@ describe("Init group and add banks with asset category flags", () => { assert.equal(bank.config.assetTag, ASSET_TAG_SOL); }); - it("(admin) Add bank (Staked SOL) - is tagged as Staked", async () => { + it("(admin) Tries to add staked bank WITH permission - should fail", async () => { let setConfig = defaultBankConfig(oracles.wsolOracle.publicKey); setConfig.assetTag = ASSET_TAG_STAKED; - // Staked assets aren't designed to be borrowed... setConfig.borrowLimit = new BN(0); let bankKeypair = Keypair.generate(); - validators[0].bank = bankKeypair.publicKey; let tx = new Transaction(); tx.add( - await addBank(program, { + await addBank(groupAdmin.userMarginProgram, { marginfiGroup: marginfiGroup.publicKey, admin: groupAdmin.wallet.publicKey, feePayer: groupAdmin.wallet.publicKey, bankMint: validators[0].splMint, - bank: validators[0].bank, + bank: bankKeypair.publicKey, config: setConfig, }) ); tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); tx.sign(groupAdmin.wallet, bankKeypair); + let result = await banksClient.tryProcessTransaction(tx); + assertBankrunTxFailed(result, "0x17a2"); + }); + + it("(admin) Add bank (validator 0) permissionless - is tagged as Staked", async () => { + const [bankKey] = deriveBankWithSeed( + program.programId, + marginfiGroup.publicKey, + validators[0].splMint, + new BN(0) + ); + validators[0].bank = bankKey; + + let tx = new Transaction(); + tx.add( + await addBankPermissionless(program, { + marginfiGroup: marginfiGroup.publicKey, + feePayer: groupAdmin.wallet.publicKey, + pythOracle: oracles.wsolOracle.publicKey, + stakePool: validators[0].splPool, + seed: new BN(0), + }) + ); + tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + tx.sign(groupAdmin.wallet); await banksClient.processTransaction(tx); if (verbose) { console.log("*init LST bank " + validators[0].bank + " (validator 0)"); } - const bank = await bankrunProgram.account.bank.fetch(validators[0].bank); assert.equal(bank.config.assetTag, ASSET_TAG_STAKED); // Note: This field is set for all banks, but only relevant for ASSET_TAG_STAKED banks. assertI80F48Equal(bank.solAppreciationRate, I80F48_ONE); + // TODO assert other relevant default values... }); - - // TODO add an LST-based pool without permission }); diff --git a/tests/s05_solAppreciates.spec.ts b/tests/s05_solAppreciates.spec.ts index da1d2fdb2..f97d54b50 100644 --- a/tests/s05_solAppreciates.spec.ts +++ b/tests/s05_solAppreciates.spec.ts @@ -82,7 +82,7 @@ describe("Borrow power grows as v0 Staked SOL gains value from appreciation", () await cacheSolExchangeRate(program, { bank: validators[0].bank, lstMint: validators[0].splMint, - solPool: validators[0].splStake, + solPool: validators[0].splSolPool, stakePool: validators[0].splPool, }) ); @@ -123,7 +123,7 @@ describe("Borrow power grows as v0 Staked SOL gains value from appreciation", () tx.add( SystemProgram.transfer({ fromPubkey: wallet.publicKey, - toPubkey: validators[0].splStake, + toPubkey: validators[0].splSolPool, lamports: appreciation * LAMPORTS_PER_SOL, }) ); @@ -141,7 +141,7 @@ describe("Borrow power grows as v0 Staked SOL gains value from appreciation", () await cacheSolExchangeRate(program, { bank: validators[0].bank, lstMint: validators[0].splMint, - solPool: validators[0].splStake, + solPool: validators[0].splSolPool, stakePool: validators[0].splPool, }) ); diff --git a/tests/utils/genericTests.ts b/tests/utils/genericTests.ts index 18fa4d544..f9b0a2f79 100644 --- a/tests/utils/genericTests.ts +++ b/tests/utils/genericTests.ts @@ -187,6 +187,7 @@ export const assertBankrunTxFailed = ( result: BanksTransactionResultWithMeta, expectedErrorCode: string ) => { + expectedErrorCode = expectedErrorCode.toLocaleLowerCase(); assert(result.meta.logMessages.length > 0); assert(result.result, "TX succeeded when it should have failed"); const lastLog = result.meta.logMessages.pop(); diff --git a/tests/utils/group-instructions.ts b/tests/utils/group-instructions.ts index 5780678c5..9a50df602 100644 --- a/tests/utils/group-instructions.ts +++ b/tests/utils/group-instructions.ts @@ -2,6 +2,7 @@ import { BN, Program } from "@coral-xyz/anchor"; import { AccountMeta, PublicKey, SYSVAR_RENT_PUBKEY } from "@solana/web3.js"; import { Marginfi } from "../../target/types/marginfi"; import { + deriveBankWithSeed, deriveFeeVault, deriveFeeVaultAuthority, deriveInsuranceVault, @@ -13,6 +14,7 @@ import { import { BankConfig, BankConfigOptWithAssetTag, + SINGLE_POOL_PROGRAM_ID, StakedSettingsConfig, StakedSettingsEdit, } from "./types"; @@ -319,7 +321,6 @@ export const editGlobalFeeState = ( export type InitStakedSettingsArgs = { group: PublicKey; - admin: PublicKey; feePayer: PublicKey; settings: StakedSettingsConfig; }; @@ -332,7 +333,7 @@ export const initStakedSettings = ( .initStakedSettings(args.settings) .accounts({ marginfiGroup: args.group, - admin: args.admin, + // admin: args.admin, // implied from group feePayer: args.feePayer, // staked_settings: deriveStakedSettings() // rent = SYSVAR_RENT_PUBKEY, @@ -344,9 +345,7 @@ export const initStakedSettings = ( }; export type EditStakedSettingsArgs = { - group: PublicKey; - admin: PublicKey; - feePayer: PublicKey; + settingsKey: PublicKey; settings: StakedSettingsEdit; }; @@ -354,13 +353,12 @@ export const editStakedSettings = ( program: Program, args: EditStakedSettingsArgs ) => { - let [settingsKey] = deriveStakedSettings(program.programId, args.group); const ix = program.methods .editStakedSettings(args.settings) .accounts({ - // marginfiGroup: args.group, // implied from settings - admin: args.admin, - stakedSettings: settingsKey, + // marginfiGroup: args.group, // implied from stakedSettings + // admin: args.admin, // implied from group + stakedSettings: args.settingsKey, // rent = SYSVAR_RENT_PUBKEY, // systemProgram: SystemProgram.programId, }) @@ -368,3 +366,73 @@ export const editStakedSettings = ( return ix; }; + +export type AddBankPermissionlessArgs = { + marginfiGroup: PublicKey; + feePayer: PublicKey; + pythOracle: PublicKey; + stakePool: PublicKey; + seed: BN; +}; + +export const addBankPermissionless = ( + program: Program, + args: AddBankPermissionlessArgs +) => { + const [settingsKey] = deriveStakedSettings( + program.programId, + args.marginfiGroup + ); + const [lstMint] = PublicKey.findProgramAddressSync( + [Buffer.from("mint"), args.stakePool.toBuffer()], + SINGLE_POOL_PROGRAM_ID + ); + const [solPool] = PublicKey.findProgramAddressSync( + [Buffer.from("stake"), args.stakePool.toBuffer()], + SINGLE_POOL_PROGRAM_ID + ); + + // Note: oracle and lst mint/pool are also passed in meta for validation + const oracleMeta: AccountMeta = { + pubkey: args.pythOracle, + isSigner: false, + isWritable: false, + }; + const lstMeta: AccountMeta = { + pubkey: lstMint, + isSigner: false, + isWritable: false, + }; + const solPoolMeta: AccountMeta = { + pubkey: solPool, + isSigner: false, + isWritable: false, + }; + + const ix = program.methods + .lendingPoolAddBankPermissionless(args.seed) + .accounts({ + // marginfiGroup: args.marginfiGroup, // implied from stakedSettings + stakedSettings: settingsKey, + feePayer: args.feePayer, + bankMint: lstMint, + solPool: solPool, + stakePool: args.stakePool, + // bank: bankKey, // deriveBankWithSeed + // globalFeeState: deriveGlobalFeeState(id), + // globalFeeWallet: // implied from globalFeeState, + // liquidityVaultAuthority = deriveLiquidityVaultAuthority(id, bank); + // liquidityVault = deriveLiquidityVault(id, bank); + // insuranceVaultAuthority = deriveInsuranceVaultAuthority(id, bank); + // insuranceVault = deriveInsuranceVault(id, bank); + // feeVaultAuthority = deriveFeeVaultAuthority(id, bank); + // feeVault = deriveFeeVault(id, bank); + // rent = SYSVAR_RENT_PUBKEY + tokenProgram: TOKEN_PROGRAM_ID, + // systemProgram: SystemProgram.programId, + }) + .remainingAccounts([oracleMeta, lstMeta, solPoolMeta]) + .instruction(); + + return ix; +}; diff --git a/tests/utils/mocks.ts b/tests/utils/mocks.ts index 4c405e838..7b2812bfe 100644 --- a/tests/utils/mocks.ts +++ b/tests/utils/mocks.ts @@ -361,13 +361,14 @@ export type Validator = { authorizedVoter: PublicKey; authorizedWithdrawer: PublicKey; voteAccount: PublicKey; + /** The spl stake pool itself, all PDAs derive from this key */ splPool: PublicKey; /** spl pool's mint for the LST (a PDA automatically created on init) */ splMint: PublicKey; /** spl pool's authority for LST management, a PDA with no data/lamports */ splAuthority: PublicKey; /** spl pool's stake account (a PDA automatically created on init, contains the SOL held by the pool) */ - splStake: PublicKey; + splSolPool: PublicKey; /** bank created for this validator's LST on the "main" group */ bank: PublicKey; }; diff --git a/tests/utils/pdas.ts b/tests/utils/pdas.ts index 51a392bca..3780dd11c 100644 --- a/tests/utils/pdas.ts +++ b/tests/utils/pdas.ts @@ -1,3 +1,4 @@ +import { BN } from "@coral-xyz/anchor"; import { PublicKey } from "@solana/web3.js"; export const deriveLiquidityVaultAuthority = ( @@ -81,6 +82,18 @@ export const deriveEmissionsTokenAccount = ( ); }; +export const deriveBankWithSeed = ( + programId: PublicKey, + group: PublicKey, + bankMint: PublicKey, + seed: BN +) => { + return PublicKey.findProgramAddressSync( + [group.toBuffer(), bankMint.toBuffer(), seed.toArrayLike(Buffer, "le", 8)], + programId + ); +}; + // ************* Below this line, not yet included in package **************** export const deriveGlobalFeeState = (programId: PublicKey) => { From 64b9f00c9c4473d5215634d5964a1db95883d074 Mon Sep 17 00:00:00 2001 From: jgur-psyops Date: Wed, 30 Oct 2024 20:14:10 -0400 Subject: [PATCH 39/52] Unhappy path tests for permissionless account validation --- .../instructions/marginfi_group/add_pool.rs | 3 +- .../marginfi_group/add_pool_permissionless.rs | 55 ++--- .../marginfi_group/add_pool_with_seed.rs | 17 +- .../marginfi_group/configure_bank.rs | 10 +- .../propagate_staked_settings.rs | 4 +- programs/marginfi/src/state/marginfi_group.rs | 18 +- programs/marginfi/src/state/price.rs | 93 +++++++-- tests/rootHooks.ts | 2 +- tests/s02_addBank.spec.ts | 191 +++++++++++++++++- 9 files changed, 328 insertions(+), 65 deletions(-) diff --git a/programs/marginfi/src/instructions/marginfi_group/add_pool.rs b/programs/marginfi/src/instructions/marginfi_group/add_pool.rs index d9e7571c2..439b127df 100644 --- a/programs/marginfi/src/instructions/marginfi_group/add_pool.rs +++ b/programs/marginfi/src/instructions/marginfi_group/add_pool.rs @@ -74,7 +74,8 @@ pub fn lending_pool_add_bank( ); bank.config.validate()?; - bank.config.validate_oracle_setup(ctx.remaining_accounts)?; + bank.config + .validate_oracle_setup(ctx.remaining_accounts, None, None, None)?; emit!(LendingPoolBankCreateEvent { header: GroupEventHeader { diff --git a/programs/marginfi/src/instructions/marginfi_group/add_pool_permissionless.rs b/programs/marginfi/src/instructions/marginfi_group/add_pool_permissionless.rs index 16e26478a..4a79a2e34 100644 --- a/programs/marginfi/src/instructions/marginfi_group/add_pool_permissionless.rs +++ b/programs/marginfi/src/instructions/marginfi_group/add_pool_permissionless.rs @@ -5,7 +5,7 @@ use crate::{ constants::{ ASSET_TAG_STAKED, FEE_VAULT_AUTHORITY_SEED, FEE_VAULT_SEED, INSURANCE_VAULT_AUTHORITY_SEED, INSURANCE_VAULT_SEED, LIQUIDITY_VAULT_AUTHORITY_SEED, LIQUIDITY_VAULT_SEED, - NATIVE_STAKE_ID, SPL_SINGLE_POOL_ID, + SPL_SINGLE_POOL_ID, }, events::{GroupEventHeader, LendingPoolBankCreateEvent}, state::{ @@ -67,7 +67,7 @@ pub fn lending_pool_add_bank_permissionless( interest_rate_config: default_ir_config.into(), // placeholder operational_state: BankOperationalState::Operational, oracle_setup: OracleSetup::StakedWithPythPush, - oracle_key: settings.oracle, + oracle_key: settings.oracle, // becomes config.oracle_keys[0] borrow_limit: 0, risk_tier: settings.risk_tier, asset_tag: ASSET_TAG_STAKED, @@ -93,40 +93,24 @@ pub fn lending_pool_add_bank_permissionless( fee_vault_authority_bump, ); - { - let program_id = &SPL_SINGLE_POOL_ID; - let mint_actual = bank_mint.key(); - let stake_pool_bytes = &ctx.accounts.stake_pool.key().to_bytes(); - // Validate the given stake_pool derives the same lst_mint, proving stake_pool is correct - let (exp_mint, _) = Pubkey::find_program_address(&[b"mint", stake_pool_bytes], program_id); - check!( - exp_mint == mint_actual, - MarginfiError::StakePoolValidationFailed - ); - // Validate the now-proven stake_pool derives the given sol_pool - let (exp_pool, _) = Pubkey::find_program_address(&[b"stake", stake_pool_bytes], program_id); - check!( - exp_pool == ctx.accounts.sol_pool.key(), - MarginfiError::StakePoolValidationFailed - ); - // Sanity check these accounts exist and have the correct owning program. Note: Mint's owner - // is already checked by the Mint anchor decorator - check!( - ctx.accounts.stake_pool.owner == program_id, - MarginfiError::StakePoolValidationFailed - ); - check!( - ctx.accounts.sol_pool.owner == &NATIVE_STAKE_ID, - MarginfiError::StakePoolValidationFailed - ); - - // The mint (for supply) and stake pool (for sol balance) are recorded for price calculation - bank.config.oracle_keys[1] = mint_actual.key(); - bank.config.oracle_keys[2] = ctx.accounts.sol_pool.key(); - } - bank.config.validate()?; - bank.config.validate_oracle_setup(ctx.remaining_accounts)?; + + check!( + ctx.accounts.stake_pool.owner == &SPL_SINGLE_POOL_ID, + MarginfiError::StakePoolValidationFailed + ); + let lst_mint = bank_mint.key(); + let stake_pool = ctx.accounts.stake_pool.key(); + let sol_pool = ctx.accounts.sol_pool.key(); + // The mint (for supply) and stake pool (for sol balance) are recorded for price calculation + bank.config.oracle_keys[1] = stake_pool; + bank.config.oracle_keys[2] = sol_pool; + bank.config.validate_oracle_setup( + ctx.remaining_accounts, + Some(lst_mint), + Some(stake_pool), + Some(sol_pool), + )?; emit!(LendingPoolBankCreateEvent { header: GroupEventHeader { @@ -155,7 +139,6 @@ pub struct LendingPoolAddBankPermissionless<'info> { /// Mint of the spl-single-pool LST (a PDA derived from `stake_pool`) /// - /// TODO test the below assumption /// CHECK: passing a mint here that is not actually a staked collateral LST is not possible /// because the sol_pool and stake_pool will not derive to a valid PDA which is also owned by /// the staking program and spl-single-pool program. diff --git a/programs/marginfi/src/instructions/marginfi_group/add_pool_with_seed.rs b/programs/marginfi/src/instructions/marginfi_group/add_pool_with_seed.rs index 033cc6f2f..486b78ce4 100644 --- a/programs/marginfi/src/instructions/marginfi_group/add_pool_with_seed.rs +++ b/programs/marginfi/src/instructions/marginfi_group/add_pool_with_seed.rs @@ -1,10 +1,16 @@ use crate::{ - check, constants::{ - ASSET_TAG_STAKED, FEE_STATE_SEED, FEE_VAULT_AUTHORITY_SEED, FEE_VAULT_SEED, INSURANCE_VAULT_AUTHORITY_SEED, INSURANCE_VAULT_SEED, LIQUIDITY_VAULT_AUTHORITY_SEED, LIQUIDITY_VAULT_SEED - }, events::{GroupEventHeader, LendingPoolBankCreateEvent}, state::{ + check, + constants::{ + ASSET_TAG_STAKED, FEE_STATE_SEED, FEE_VAULT_AUTHORITY_SEED, FEE_VAULT_SEED, + INSURANCE_VAULT_AUTHORITY_SEED, INSURANCE_VAULT_SEED, LIQUIDITY_VAULT_AUTHORITY_SEED, + LIQUIDITY_VAULT_SEED, + }, + events::{GroupEventHeader, LendingPoolBankCreateEvent}, + state::{ fee_state::FeeState, marginfi_group::{Bank, BankConfig, BankConfigCompact, MarginfiGroup}, - }, MarginfiError, MarginfiResult + }, + MarginfiError, MarginfiResult, }; use anchor_lang::prelude::*; use anchor_spl::token_interface::*; @@ -68,7 +74,8 @@ pub fn lending_pool_add_bank_with_seed( ); bank.config.validate()?; - bank.config.validate_oracle_setup(ctx.remaining_accounts)?; + bank.config + .validate_oracle_setup(ctx.remaining_accounts, None, None, None)?; emit!(LendingPoolBankCreateEvent { header: GroupEventHeader { diff --git a/programs/marginfi/src/instructions/marginfi_group/configure_bank.rs b/programs/marginfi/src/instructions/marginfi_group/configure_bank.rs index 900d31232..9b26f2fbe 100644 --- a/programs/marginfi/src/instructions/marginfi_group/configure_bank.rs +++ b/programs/marginfi/src/instructions/marginfi_group/configure_bank.rs @@ -1,4 +1,4 @@ -use crate::constants::{EMISSIONS_AUTH_SEED, EMISSIONS_TOKEN_ACCOUNT_SEED}; +use crate::constants::{ASSET_TAG_STAKED, EMISSIONS_AUTH_SEED, EMISSIONS_TOKEN_ACCOUNT_SEED}; use crate::events::{GroupEventHeader, LendingPoolBankConfigureEvent}; use crate::prelude::MarginfiError; use crate::{check, math_error, utils}; @@ -20,7 +20,13 @@ pub fn lending_pool_configure_bank( bank.configure(&bank_config)?; if bank_config.oracle.is_some() { - bank.config.validate_oracle_setup(ctx.remaining_accounts)?; + if bank.config.asset_tag == ASSET_TAG_STAKED { + bank.config + .validate_staked_oracle_setup(ctx.remaining_accounts)?; + } else { + bank.config + .validate_oracle_setup(ctx.remaining_accounts, None, None, None)?; + } } emit!(LendingPoolBankConfigureEvent { diff --git a/programs/marginfi/src/instructions/marginfi_group/propagate_staked_settings.rs b/programs/marginfi/src/instructions/marginfi_group/propagate_staked_settings.rs index 1b7d4e399..9f1d8595e 100644 --- a/programs/marginfi/src/instructions/marginfi_group/propagate_staked_settings.rs +++ b/programs/marginfi/src/instructions/marginfi_group/propagate_staked_settings.rs @@ -18,8 +18,8 @@ pub fn propagate_staked_settings(ctx: Context) -> Resul bank.config.risk_tier = settings.risk_tier; bank.config.validate()?; - - // ...Possibly validate oracle or emit event. + bank.config.validate_staked_oracle_setup(ctx.remaining_accounts)?; + // ...Possibly emit event. Ok(()) } diff --git a/programs/marginfi/src/state/marginfi_group.rs b/programs/marginfi/src/state/marginfi_group.rs index e591cb598..4a6a1be96 100644 --- a/programs/marginfi/src/state/marginfi_group.rs +++ b/programs/marginfi/src/state/marginfi_group.rs @@ -1449,8 +1449,22 @@ impl BankConfig { self.borrow_limit != u64::MAX } - pub fn validate_oracle_setup(&self, ais: &[AccountInfo]) -> MarginfiResult { - OraclePriceFeedAdapter::validate_bank_config(self, ais)?; + pub fn validate_oracle_setup( + &self, + ais: &[AccountInfo], + lst_mint: Option, + stake_pool: Option, + sol_pool: Option, + ) -> MarginfiResult { + OraclePriceFeedAdapter::validate_bank_config(self, ais, lst_mint, stake_pool, sol_pool)?; + Ok(()) + } + + /// Because the mint (and thus corresponding stake pool) of a staked collateral bank cannot update + /// after inception, this function validates just the oracle, ignoring the lst mint and sol pool, + /// for oracles configured as StakedWithPythPush, and otherwise errors + pub fn validate_staked_oracle_setup(&self, ais: &[AccountInfo]) -> MarginfiResult { + OraclePriceFeedAdapter::validate_staked_bank_config_light(self, ais)?; Ok(()) } diff --git a/programs/marginfi/src/state/price.rs b/programs/marginfi/src/state/price.rs index db4e72fd1..36e56ed3f 100644 --- a/programs/marginfi/src/state/price.rs +++ b/programs/marginfi/src/state/price.rs @@ -16,8 +16,8 @@ use crate::{ check, constants::{ CONF_INTERVAL_MULTIPLE, EXP_10, EXP_10_I80F48, MAX_CONF_INTERVAL, - MIN_PYTH_PUSH_VERIFICATION_LEVEL, NATIVE_STAKE_ID, PYTH_ID, STD_DEV_MULTIPLE, - SWITCHBOARD_PULL_ID, + MIN_PYTH_PUSH_VERIFICATION_LEVEL, NATIVE_STAKE_ID, PYTH_ID, SPL_SINGLE_POOL_ID, + STD_DEV_MULTIPLE, SWITCHBOARD_PULL_ID, }, debug, math_error, prelude::*, @@ -156,9 +156,13 @@ impl OraclePriceFeedAdapter { } } + /// * lst_mint, stake_pool, sol_pool - required only if configuring `OracleSetup::StakedWithPythPush` pub fn validate_bank_config( bank_config: &BankConfig, oracle_ais: &[AccountInfo], + lst_mint: Option, + stake_pool: Option, + sol_pool: Option, ) -> MarginfiResult { match bank_config.oracle_setup { OracleSetup::None => Err(MarginfiError::OracleNotSetup.into()), @@ -187,6 +191,11 @@ impl OraclePriceFeedAdapter { OracleSetup::PythPushOracle => { check!(oracle_ais.len() == 1, MarginfiError::InvalidOracleAccount); + check!( + oracle_ais[0].key == &bank_config.oracle_keys[0], + MarginfiError::InvalidOracleAccount + ); + PythPushOraclePriceFeed::check_ai_and_feed_id( &oracle_ais[0], bank_config.get_pyth_push_oracle_feed_id().unwrap(), @@ -207,6 +216,12 @@ impl OraclePriceFeedAdapter { } OracleSetup::StakedWithPythPush => { check!(oracle_ais.len() == 3, MarginfiError::InvalidOracleAccount); + + check!( + oracle_ais[0].key == &bank_config.oracle_keys[0], + MarginfiError::InvalidOracleAccount + ); + // Note: mainnet/staging/devnet use push oracles, localnet uses legacy push if cfg!(any( feature = "mainnet-beta", @@ -219,26 +234,43 @@ impl OraclePriceFeedAdapter { )?; } else { // Localnet only - check!( - oracle_ais[0].key == &bank_config.oracle_keys[0], - MarginfiError::InvalidOracleAccount - ); - PythLegacyPriceFeed::check_ais(&oracle_ais[0])?; } - // Sanity checks (PDA validation for these accounts happens once, at bank initialization) + check!( + lst_mint.is_some() && stake_pool.is_some() && sol_pool.is_some(), + MarginfiError::StakePoolValidationFailed + ); + let lst_mint = lst_mint.unwrap(); + let stake_pool = stake_pool.unwrap(); + let sol_pool = sol_pool.unwrap(); + + let program_id = &SPL_SINGLE_POOL_ID; + let stake_pool_bytes = &stake_pool.to_bytes(); + // Validate the given stake_pool derives the same lst_mint, proving stake_pool is correct + let (exp_mint, _) = + Pubkey::find_program_address(&[b"mint", stake_pool_bytes], program_id); + check!( + exp_mint == lst_mint, + MarginfiError::StakePoolValidationFailed + ); + // Validate the now-proven stake_pool derives the given sol_pool + let (exp_pool, _) = + Pubkey::find_program_address(&[b"stake", stake_pool_bytes], program_id); + check!( + exp_pool == sol_pool.key(), + MarginfiError::StakePoolValidationFailed + ); - // The spl token mint (to obtain supply information). Note: spl-single-pool uses a - // classic Token, never Token22 + // Sanity check the mint. Note: spl-single-pool uses a classic Token, never Token22 check!( - oracle_ais[1].owner == &SPL_TOKEN_PROGRAM_ID, + oracle_ais[1].owner == &SPL_TOKEN_PROGRAM_ID && oracle_ais[1].key() == lst_mint, MarginfiError::StakePoolValidationFailed ); - // The spl stake pool (to obtain the balance of staked SOL). Note: the native - // staking program is written in vanilla Rust and has no Anchor discriminator. + // Sanity check the pool is a native stake pool. Note: the native staking program is + // written in vanilla Solana and has no Anchor discriminator. check!( - oracle_ais[2].owner == &NATIVE_STAKE_ID, + oracle_ais[2].owner == &NATIVE_STAKE_ID && oracle_ais[2].key() == sol_pool, MarginfiError::StakePoolValidationFailed ); @@ -246,6 +278,39 @@ impl OraclePriceFeedAdapter { } } } + + pub fn validate_staked_bank_config_light( + bank_config: &BankConfig, + oracle_ais: &[AccountInfo], + ) -> MarginfiResult { + match bank_config.oracle_setup { + OracleSetup::StakedWithPythPush => Ok(()), + _ => err!(MarginfiError::StakePoolValidationFailed), + }?; + + check!( + oracle_ais[0].key == &bank_config.oracle_keys[0], + MarginfiError::InvalidOracleAccount + ); + + check!(oracle_ais.len() == 1, MarginfiError::InvalidOracleAccount); + // Note: mainnet/staging/devnet use push oracles, localnet uses legacy push + if cfg!(any( + feature = "mainnet-beta", + feature = "staging", + feature = "devnet" + )) { + PythPushOraclePriceFeed::check_ai_and_feed_id( + &oracle_ais[0], + bank_config.get_pyth_push_oracle_feed_id().unwrap(), + )?; + } else { + // Localnet only + PythLegacyPriceFeed::check_ais(&oracle_ais[0])?; + } + + Ok(()) + } } #[cfg_attr(feature = "client", derive(Clone, Debug))] diff --git a/tests/rootHooks.ts b/tests/rootHooks.ts index 0403df93c..a17a6a347 100644 --- a/tests/rootHooks.ts +++ b/tests/rootHooks.ts @@ -56,7 +56,7 @@ export const users: MockUser[] = []; export const numUsers = 3; export const validators: Validator[] = []; -export const numValidators = 1; +export const numValidators = 2; export let globalFeeWallet: PublicKey = undefined; /** Lamports charged when creating any pool */ diff --git a/tests/s02_addBank.spec.ts b/tests/s02_addBank.spec.ts index 793d1936a..f208e5b22 100644 --- a/tests/s02_addBank.spec.ts +++ b/tests/s02_addBank.spec.ts @@ -1,5 +1,5 @@ import { BN, Program, workspace } from "@coral-xyz/anchor"; -import { Keypair, Transaction } from "@solana/web3.js"; +import { AccountMeta, Keypair, PublicKey, Transaction } from "@solana/web3.js"; import { addBank, addBankPermissionless, @@ -17,6 +17,7 @@ import { groupAdmin, marginfiGroup, oracles, + users, validators, verbose, } from "./rootHooks"; @@ -39,6 +40,7 @@ import { import { assert } from "chai"; import { getBankrunBlockhash } from "./utils/spl-staking-utils"; import { deriveBankWithSeed, deriveStakedSettings } from "./utils/pdas"; +import { TOKEN_PROGRAM_ID } from "@solana/spl-token"; describe("Init group and add banks with asset category flags", () => { const program = workspace.Marginfi as Program; @@ -184,7 +186,192 @@ describe("Init group and add banks with asset category flags", () => { assertBankrunTxFailed(result, "0x17a2"); }); - it("(admin) Add bank (validator 0) permissionless - is tagged as Staked", async () => { + it("(attacker) Add bank (validator 0) with bad accounts + metadata - should fail", async () => { + const [settingsKey] = deriveStakedSettings( + program.programId, + marginfiGroup.publicKey + ); + const goodStakePool = validators[0].splPool; + const goodLstMint = validators[0].splMint; + const goodSolPool = validators[0].splSolPool; + + // Attacker tries to sneak in the wrong validator's information + const badStakePool = validators[1].splPool; + const badLstMint = validators[1].splMint; + const badSolPool = validators[1].splSolPool; + + const stakePools = [goodStakePool, badStakePool]; + const lstMints = [goodLstMint, badLstMint]; + const solPools = [goodSolPool, badSolPool]; + + for (const stakePool of stakePools) { + for (const lstMint of lstMints) { + for (const solPool of solPools) { + // Skip the "all good" combination + if ( + stakePool.equals(goodStakePool) && + lstMint.equals(goodLstMint) && + solPool.equals(goodSolPool) + ) { + continue; + } + + // Skip the "all bad" combination (equivalent to a valid init of validator 1) + if ( + stakePool.equals(badStakePool) && + lstMint.equals(badLstMint) && + solPool.equals(badSolPool) + ) { + continue; + } + + const oracleMeta: AccountMeta = { + pubkey: oracles.wsolOracle.publicKey, + isSigner: false, + isWritable: false, + }; + const lstMeta: AccountMeta = { + pubkey: lstMint, + isSigner: false, + isWritable: false, + }; + const solPoolMeta: AccountMeta = { + pubkey: solPool, + isSigner: false, + isWritable: false, + }; + + const ix = await program.methods + .lendingPoolAddBankPermissionless(new BN(0)) + .accounts({ + stakedSettings: settingsKey, + feePayer: users[0].wallet.publicKey, + bankMint: lstMint, + solPool: solPool, + stakePool: stakePool, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .remainingAccounts([oracleMeta, lstMeta, solPoolMeta]) + .instruction(); + + let tx = new Transaction(); + tx.add(ix); + tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + tx.sign(users[0].wallet); + + let result = await banksClient.tryProcessTransaction(tx); + assertBankrunTxFailed(result, "0x17a0"); + } + } + } + }); + + it("(attacker) Add bank (validator 0) with good accounts but bad metadata - should fail", async () => { + const [settingsKey] = deriveStakedSettings( + program.programId, + marginfiGroup.publicKey + ); + + const goodStakePool = validators[0].splPool; + const goodLstMint = validators[0].splMint; + const goodSolPool = validators[0].splSolPool; + + // Note: StakePool is N/A because we do not pass StakePool in meta. + // const badStakePool = validators[1].splPool; + const badLstMint = validators[1].splMint; + const badSolPool = validators[1].splSolPool; + + // Test all combinations of bad metadata keys + const lstMints = [goodLstMint, badLstMint]; + const solPools = [goodSolPool, badSolPool]; + + for (const lstMint of lstMints) { + for (const solPool of solPools) { + // Skip the all-good metadata case + if (lstMint.equals(goodLstMint) && solPool.equals(goodSolPool)) { + continue; + } + + const oracleMeta: AccountMeta = { + pubkey: oracles.wsolOracle.publicKey, + isSigner: false, + isWritable: false, + }; + const lstMeta: AccountMeta = { + pubkey: lstMint, + isSigner: false, + isWritable: false, + }; + const solPoolMeta: AccountMeta = { + pubkey: solPool, + isSigner: false, + isWritable: false, + }; + + const ix = await program.methods + .lendingPoolAddBankPermissionless(new BN(0)) + .accounts({ + stakedSettings: settingsKey, + feePayer: users[0].wallet.publicKey, + bankMint: goodLstMint, // Good key + solPool: goodSolPool, // Good key + stakePool: goodStakePool, // Good key + tokenProgram: TOKEN_PROGRAM_ID, + }) + .remainingAccounts([oracleMeta, lstMeta, solPoolMeta]) // Bad metadata keys + .instruction(); + + let tx = new Transaction(); + tx.add(ix); + tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + tx.sign(users[0].wallet); + + let result = await banksClient.tryProcessTransaction(tx); + assertBankrunTxFailed(result, "0x17a0"); + } + } + + // Bad oracle meta + const oracleMeta: AccountMeta = { + pubkey: oracles.usdcOracle.publicKey, // Bad meta + isSigner: false, + isWritable: false, + }; + const lstMeta: AccountMeta = { + pubkey: goodLstMint, + isSigner: false, + isWritable: false, + }; + const solPoolMeta: AccountMeta = { + pubkey: goodSolPool, + isSigner: false, + isWritable: false, + }; + + const ix = await program.methods + .lendingPoolAddBankPermissionless(new BN(0)) + .accounts({ + stakedSettings: settingsKey, + feePayer: users[0].wallet.publicKey, + bankMint: goodLstMint, // Good key + solPool: goodSolPool, // Good key + stakePool: goodStakePool, // Good key + tokenProgram: TOKEN_PROGRAM_ID, + }) + .remainingAccounts([oracleMeta, lstMeta, solPoolMeta]) // Bad oracle meta + .instruction(); + + let tx = new Transaction(); + tx.add(ix); + tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + tx.sign(users[0].wallet); + + let result = await banksClient.tryProcessTransaction(tx); + // Note: different error + assertBankrunTxFailed(result, "0x1777"); + }); + + it("(permissionless) Add bank (validator 0) - is tagged as Staked", async () => { const [bankKey] = deriveBankWithSeed( program.programId, marginfiGroup.publicKey, From 8107742f982a514eefa767fe002b9295956ebf0f Mon Sep 17 00:00:00 2001 From: jgur-psyops Date: Wed, 30 Oct 2024 20:42:23 -0400 Subject: [PATCH 40/52] Happy path validation for permissionless bank creation --- .../marginfi_group/add_pool_permissionless.rs | 10 +- programs/marginfi/src/state/marginfi_group.rs | 7 +- tests/s02_addBank.spec.ts | 103 +++++++++++++++++- 3 files changed, 110 insertions(+), 10 deletions(-) diff --git a/programs/marginfi/src/instructions/marginfi_group/add_pool_permissionless.rs b/programs/marginfi/src/instructions/marginfi_group/add_pool_permissionless.rs index 4a79a2e34..51c1b9cce 100644 --- a/programs/marginfi/src/instructions/marginfi_group/add_pool_permissionless.rs +++ b/programs/marginfi/src/instructions/marginfi_group/add_pool_permissionless.rs @@ -31,6 +31,8 @@ pub fn lending_pool_add_bank_permissionless( insurance_vault, fee_vault, bank: bank_loader, + stake_pool, + sol_pool, .. } = ctx.accounts; @@ -96,14 +98,14 @@ pub fn lending_pool_add_bank_permissionless( bank.config.validate()?; check!( - ctx.accounts.stake_pool.owner == &SPL_SINGLE_POOL_ID, + stake_pool.owner == &SPL_SINGLE_POOL_ID, MarginfiError::StakePoolValidationFailed ); let lst_mint = bank_mint.key(); - let stake_pool = ctx.accounts.stake_pool.key(); - let sol_pool = ctx.accounts.sol_pool.key(); + let stake_pool = stake_pool.key(); + let sol_pool = sol_pool.key(); // The mint (for supply) and stake pool (for sol balance) are recorded for price calculation - bank.config.oracle_keys[1] = stake_pool; + bank.config.oracle_keys[1] = lst_mint; bank.config.oracle_keys[2] = sol_pool; bank.config.validate_oracle_setup( ctx.remaining_accounts, diff --git a/programs/marginfi/src/state/marginfi_group.rs b/programs/marginfi/src/state/marginfi_group.rs index 4a6a1be96..9ed107cc2 100644 --- a/programs/marginfi/src/state/marginfi_group.rs +++ b/programs/marginfi/src/state/marginfi_group.rs @@ -1460,9 +1460,10 @@ impl BankConfig { Ok(()) } - /// Because the mint (and thus corresponding stake pool) of a staked collateral bank cannot update - /// after inception, this function validates just the oracle, ignoring the lst mint and sol pool, - /// for oracles configured as StakedWithPythPush, and otherwise errors + /// Because the mint (and thus corresponding stake pool) of a staked collateral bank cannot + /// update after inception, this function validates just the oracle, ignoring the lst mint and + /// sol pool. This function works only for banks configured as StakedWithPythPush, and otherwise + /// errors pub fn validate_staked_oracle_setup(&self, ais: &[AccountInfo]) -> MarginfiResult { OraclePriceFeedAdapter::validate_staked_bank_config_light(self, ais)?; Ok(()) diff --git a/tests/s02_addBank.spec.ts b/tests/s02_addBank.spec.ts index f208e5b22..68793f15e 100644 --- a/tests/s02_addBank.spec.ts +++ b/tests/s02_addBank.spec.ts @@ -26,6 +26,7 @@ import { assertBNEqual, assertI80F48Approx, assertI80F48Equal, + assertKeyDefault, assertKeysEqual, } from "./utils/genericTests"; import { @@ -39,7 +40,16 @@ import { } from "./utils/types"; import { assert } from "chai"; import { getBankrunBlockhash } from "./utils/spl-staking-utils"; -import { deriveBankWithSeed, deriveStakedSettings } from "./utils/pdas"; +import { + deriveBankWithSeed, + deriveFeeVault, + deriveFeeVaultAuthority, + deriveInsuranceVault, + deriveInsuranceVaultAuthority, + deriveLiquidityVault, + deriveLiquidityVaultAuthority, + deriveStakedSettings, +} from "./utils/pdas"; import { TOKEN_PROGRAM_ID } from "@solana/spl-token"; describe("Init group and add banks with asset category flags", () => { @@ -398,9 +408,96 @@ describe("Init group and add banks with asset category flags", () => { console.log("*init LST bank " + validators[0].bank + " (validator 0)"); } const bank = await bankrunProgram.account.bank.fetch(validators[0].bank); + const [settingsKey] = deriveStakedSettings( + program.programId, + marginfiGroup.publicKey + ); + const settingsAcc = await bankrunProgram.account.stakedSettings.fetch( + settingsKey + ); + // Noteworthy fields assert.equal(bank.config.assetTag, ASSET_TAG_STAKED); - // Note: This field is set for all banks, but only relevant for ASSET_TAG_STAKED banks. assertI80F48Equal(bank.solAppreciationRate, I80F48_ONE); - // TODO assert other relevant default values... + + // Standard fields + const config = bank.config; + const interest = config.interestRateConfig; + const id = program.programId; + + assertKeysEqual(bank.mint, validators[0].splMint); + // Note: stake accounts use SOL decimals + assert.equal(bank.mintDecimals, ecosystem.wsolDecimals); + assertKeysEqual(bank.group, marginfiGroup.publicKey); + + // Keys and bumps... + const [_liqAuth, liqAuthBump] = deriveLiquidityVaultAuthority(id, bankKey); + const [liquidityVault, liqVaultBump] = deriveLiquidityVault(id, bankKey); + assertKeysEqual(bank.liquidityVault, liquidityVault); + assert.equal(bank.liquidityVaultBump, liqVaultBump); + assert.equal(bank.liquidityVaultAuthorityBump, liqAuthBump); + + const [_insAuth, insAuthBump] = deriveInsuranceVaultAuthority(id, bankKey); + const [insuranceVault, insurVaultBump] = deriveInsuranceVault(id, bankKey); + assertKeysEqual(bank.insuranceVault, insuranceVault); + assert.equal(bank.insuranceVaultBump, insurVaultBump); + assert.equal(bank.insuranceVaultAuthorityBump, insAuthBump); + + const [_feeVaultAuth, feeAuthBump] = deriveFeeVaultAuthority(id, bankKey); + const [feeVault, feeVaultBump] = deriveFeeVault(id, bankKey); + assertKeysEqual(bank.feeVault, feeVault); + assert.equal(bank.feeVaultBump, feeVaultBump); + assert.equal(bank.feeVaultAuthorityBump, feeAuthBump); + + assertKeyDefault(bank.emissionsMint); + + // Constants/Defaults... + assertI80F48Equal(bank.assetShareValue, 1); + assertI80F48Equal(bank.liabilityShareValue, 1); + assertI80F48Equal(bank.collectedInsuranceFeesOutstanding, 0); + assertI80F48Equal(bank.collectedGroupFeesOutstanding, 0); + assertI80F48Equal(bank.totalLiabilityShares, 0); + assertI80F48Equal(bank.totalAssetShares, 0); + assertBNEqual(bank.flags, 0); + assertBNEqual(bank.emissionsRate, 0); + assertI80F48Equal(bank.emissionsRemaining, 0); + + // Settings and non-default values... + assertI80F48Approx(config.assetWeightInit, settingsAcc.assetWeightInit); + assertI80F48Approx(config.assetWeightMaint, settingsAcc.assetWeightMaint); + assertI80F48Approx(config.liabilityWeightInit, 1.5); + assertI80F48Approx(config.liabilityWeightMaint, 1.25); + assertBNEqual(config.depositLimit, settingsAcc.depositLimit); + + assertI80F48Approx(interest.optimalUtilizationRate, 0.4); + assertI80F48Approx(interest.plateauInterestRate, 0.4); + assertI80F48Approx(interest.maxInterestRate, 3); + + assertI80F48Equal(interest.insuranceFeeFixedApr, 0); + assertI80F48Approx(interest.insuranceIrFee, 0.1); + assertI80F48Approx(interest.protocolFixedFeeApr, 0.01); + assertI80F48Equal(interest.protocolIrFee, 0); + + assertI80F48Equal(interest.protocolOriginationFee, 0); + + assert.deepEqual(config.operationalState, { operational: {} }); + assert.deepEqual(config.oracleSetup, { stakedWithPythPush: {} }); + assertBNEqual(config.borrowLimit, 0); + assert.deepEqual(config.riskTier, settingsAcc.riskTier); + assert.equal(config.assetTag, ASSET_TAG_STAKED); + assertBNEqual( + config.totalAssetValueInitLimit, + settingsAcc.totalAssetValueInitLimit + ); + + // Oracle information.... + assert.equal(config.oracleMaxAge, settingsAcc.oracleMaxAge); + assertKeysEqual(config.oracleKeys[0], settingsAcc.oracle); + assertKeysEqual(config.oracleKeys[1], validators[0].splMint); + assertKeysEqual(config.oracleKeys[2], validators[0].splSolPool); + + assertI80F48Equal(bank.collectedProgramFeesOutstanding, 0); + + // Timing is annoying to test in bankrun context due to clock warping + // assert.approximately(now, bank.lastUpdate.toNumber(), 2); }); }); From 4eece773220736b48de6f2fc4fd2343c3531f134 Mon Sep 17 00:00:00 2001 From: jgur-psyops Date: Thu, 31 Oct 2024 01:47:30 -0400 Subject: [PATCH 41/52] Fix Pyth push regression --- programs/marginfi/src/state/marginfi_group.rs | 1 + programs/marginfi/src/state/price.rs | 17 ++++++----------- tests/s02_addBank.spec.ts | 5 ++--- 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/programs/marginfi/src/state/marginfi_group.rs b/programs/marginfi/src/state/marginfi_group.rs index 9ed107cc2..81bbff3aa 100644 --- a/programs/marginfi/src/state/marginfi_group.rs +++ b/programs/marginfi/src/state/marginfi_group.rs @@ -1449,6 +1449,7 @@ impl BankConfig { self.borrow_limit != u64::MAX } + /// * lst_mint, stake_pool, sol_pool - required only if configuring `OracleSetup::StakedWithPythPush` pub fn validate_oracle_setup( &self, ais: &[AccountInfo], diff --git a/programs/marginfi/src/state/price.rs b/programs/marginfi/src/state/price.rs index 36e56ed3f..14b3e1821 100644 --- a/programs/marginfi/src/state/price.rs +++ b/programs/marginfi/src/state/price.rs @@ -191,11 +191,6 @@ impl OraclePriceFeedAdapter { OracleSetup::PythPushOracle => { check!(oracle_ais.len() == 1, MarginfiError::InvalidOracleAccount); - check!( - oracle_ais[0].key == &bank_config.oracle_keys[0], - MarginfiError::InvalidOracleAccount - ); - PythPushOraclePriceFeed::check_ai_and_feed_id( &oracle_ais[0], bank_config.get_pyth_push_oracle_feed_id().unwrap(), @@ -217,12 +212,7 @@ impl OraclePriceFeedAdapter { OracleSetup::StakedWithPythPush => { check!(oracle_ais.len() == 3, MarginfiError::InvalidOracleAccount); - check!( - oracle_ais[0].key == &bank_config.oracle_keys[0], - MarginfiError::InvalidOracleAccount - ); - - // Note: mainnet/staging/devnet use push oracles, localnet uses legacy push + // Note: mainnet/staging/devnet use "push" oracles, localnet uses legacy if cfg!(any( feature = "mainnet-beta", feature = "staging", @@ -234,6 +224,11 @@ impl OraclePriceFeedAdapter { )?; } else { // Localnet only + check!( + oracle_ais[0].key == &bank_config.oracle_keys[0], + MarginfiError::InvalidOracleAccount + ); + PythLegacyPriceFeed::check_ais(&oracle_ais[0])?; } diff --git a/tests/s02_addBank.spec.ts b/tests/s02_addBank.spec.ts index 68793f15e..2d0ab5d61 100644 --- a/tests/s02_addBank.spec.ts +++ b/tests/s02_addBank.spec.ts @@ -196,7 +196,7 @@ describe("Init group and add banks with asset category flags", () => { assertBankrunTxFailed(result, "0x17a2"); }); - it("(attacker) Add bank (validator 0) with bad accounts + metadata - should fail", async () => { + it("(attacker) Add bank (validator 0) with bad accounts + bad metadata - should fail", async () => { const [settingsKey] = deriveStakedSettings( program.programId, marginfiGroup.publicKey @@ -291,7 +291,6 @@ describe("Init group and add banks with asset category flags", () => { const badLstMint = validators[1].splMint; const badSolPool = validators[1].splSolPool; - // Test all combinations of bad metadata keys const lstMints = [goodLstMint, badLstMint]; const solPools = [goodSolPool, badSolPool]; @@ -381,7 +380,7 @@ describe("Init group and add banks with asset category flags", () => { assertBankrunTxFailed(result, "0x1777"); }); - it("(permissionless) Add bank (validator 0) - is tagged as Staked", async () => { + it("(permissionless) Add staked collateral bank (validator 0) - happy path", async () => { const [bankKey] = deriveBankWithSeed( program.programId, marginfiGroup.publicKey, From 99e076876f67ff1d7aa7fff94dcb89480dffa021 Mon Sep 17 00:00:00 2001 From: jgur-psyops Date: Thu, 31 Oct 2024 15:04:36 -0400 Subject: [PATCH 42/52] Borrow against staked assets functions as intended, adding sol appreciation --- programs/marginfi/src/errors.rs | 2 +- .../marginfi_group/cache_sol_ex_rate.rs | 76 ----------- .../src/instructions/marginfi_group/mod.rs | 2 - programs/marginfi/src/lib.rs | 7 - .../marginfi/src/state/marginfi_account.rs | 59 ++++---- programs/marginfi/src/state/price.rs | 92 ++++++++++++- tests/s04_borrow.spec.ts | 2 + tests/s05_solAppreciates.spec.ts | 129 +++++++++--------- tests/utils/group-instructions.ts | 25 ---- tests/utils/tools.ts | 18 ++- 10 files changed, 203 insertions(+), 209 deletions(-) delete mode 100644 programs/marginfi/src/instructions/marginfi_group/cache_sol_ex_rate.rs diff --git a/programs/marginfi/src/errors.rs b/programs/marginfi/src/errors.rs index 6a9834dae..18a8a4911 100644 --- a/programs/marginfi/src/errors.rs +++ b/programs/marginfi/src/errors.rs @@ -12,7 +12,7 @@ pub enum MarginfiError { BankAssetCapacityExceeded, #[msg("Invalid transfer")] // 6004 InvalidTransfer, - #[msg("Missing Pyth or Bank account")] // 6005 + #[msg("Missing Oracle, Bank, LST mint, or Sol Pool")] // 6005 MissingPythOrBankAccount, #[msg("Missing Pyth account")] // 6006 MissingPythAccount, diff --git a/programs/marginfi/src/instructions/marginfi_group/cache_sol_ex_rate.rs b/programs/marginfi/src/instructions/marginfi_group/cache_sol_ex_rate.rs deleted file mode 100644 index f3321c82d..000000000 --- a/programs/marginfi/src/instructions/marginfi_group/cache_sol_ex_rate.rs +++ /dev/null @@ -1,76 +0,0 @@ -// Permissionless ix to order a bank with ASSET_TAG_STAKED to cache the current exchange rate of -// SOL:LST (sol_appreciation_rate) on the active spl-single-pool - -use crate::{ - check, - constants::{ASSET_TAG_STAKED, SPL_SINGLE_POOL_ID}, - state::marginfi_group::Bank, - MarginfiError, MarginfiResult, -}; -use anchor_lang::prelude::*; -use anchor_spl::token_interface::*; -use fixed::types::I80F48; - -#[derive(Accounts)] -pub struct CacheSolExRate<'info> { - #[account(mut)] - pub bank: AccountLoader<'info, Bank>, - - #[account( - constraint = lst_mint.key() == bank.load()?.mint @ MarginfiError::StakePoolValidationFailed - )] - pub lst_mint: Box>, - /// CHECK: Validated using `stake_pool` - pub sol_pool: AccountInfo<'info>, - - /// CHECK: We validate this is correct backwards, by deriving the PDA of the `lst_mint` using - /// this key. Since the mint is already checked against the known value on the Bank, if it - /// derives the same `lst_mint`, then this must be the correct pool, and we can subsequently use - /// it to validate the `sol_pool` - pub stake_pool: AccountInfo<'info>, -} - -pub fn cache_sol_ex_rate(ctx: Context) -> MarginfiResult { - let mut bank = ctx.accounts.bank.load_mut()?; - let stake_pool_bytes = &ctx.accounts.stake_pool.key().to_bytes(); - - // This ix does not apply to non-staked assets, set the default value and exit - if bank.config.asset_tag != ASSET_TAG_STAKED { - bank.sol_appreciation_rate = I80F48::ONE.into(); - msg!("Wrong asset type flagged, resetting to default value and aborting"); - return Ok(()); - } - - let program_id = &SPL_SINGLE_POOL_ID; - // Validate the given stake_pool derives the same lst_mint, proving stake_pool is correct - let (exp_mint, _) = Pubkey::find_program_address(&[b"mint", stake_pool_bytes], program_id); - check!( - exp_mint == ctx.accounts.lst_mint.key(), - MarginfiError::StakePoolValidationFailed - ); - - // Validate the now-proven stake_pool derives the given sol_pool - let (exp_pool, _) = Pubkey::find_program_address(&[b"stake", stake_pool_bytes], program_id); - check!( - exp_pool == ctx.accounts.sol_pool.key(), - MarginfiError::StakePoolValidationFailed - ); - - // Note: LST mint and SOL use the same decimals, so decimals do not need to be considered - let lst_supply: I80F48 = I80F48::from(ctx.accounts.lst_mint.supply); - // Handle the edge case when the supply is zero - if lst_supply == I80F48::ZERO { - bank.sol_appreciation_rate = I80F48::ONE.into(); - return Ok(()); - } - let sol_pool_balance: I80F48 = I80F48::from(ctx.accounts.sol_pool.lamports()); - - let sol_lst_exchange_rate: I80F48 = sol_pool_balance / lst_supply; - // Sanity check the exchange rate - if sol_lst_exchange_rate < I80F48::ONE { - panic!("invalid exchange rate or slashing now exists"); - } - bank.sol_appreciation_rate = sol_lst_exchange_rate.into(); - - Ok(()) -} diff --git a/programs/marginfi/src/instructions/marginfi_group/mod.rs b/programs/marginfi/src/instructions/marginfi_group/mod.rs index f53087c4f..88a9f2d23 100644 --- a/programs/marginfi/src/instructions/marginfi_group/mod.rs +++ b/programs/marginfi/src/instructions/marginfi_group/mod.rs @@ -2,7 +2,6 @@ mod accrue_bank_interest; mod add_pool; mod add_pool_permissionless; mod add_pool_with_seed; -mod cache_sol_ex_rate; mod collect_bank_fees; mod config_group_fee; mod configure; @@ -20,7 +19,6 @@ pub use accrue_bank_interest::*; pub use add_pool::*; pub use add_pool_permissionless::*; pub use add_pool_with_seed::*; -pub use cache_sol_ex_rate::*; pub use collect_bank_fees::*; pub use config_group_fee::*; pub use configure::*; diff --git a/programs/marginfi/src/lib.rs b/programs/marginfi/src/lib.rs index 3d466fdb1..7ba7c8023 100644 --- a/programs/marginfi/src/lib.rs +++ b/programs/marginfi/src/lib.rs @@ -95,13 +95,6 @@ pub mod marginfi { ) } - /// (permissionless) Used by Staked Sol banks (`bank.config.asset_tag == ASSET_TAG_STAKED`) to - /// cache the current exchange rate of SOL:LST for that validator. Should be called roughly once - /// per epoch. - pub fn cache_sol_ex_rate(ctx: Context) -> MarginfiResult { - marginfi_group::cache_sol_ex_rate(ctx) - } - /// Handle bad debt of a bankrupt marginfi account for a given bank. pub fn lending_pool_handle_bankruptcy<'info>( ctx: Context<'_, '_, 'info, 'info, LendingPoolHandleBankruptcy<'info>>, diff --git a/programs/marginfi/src/state/marginfi_account.rs b/programs/marginfi/src/state/marginfi_account.rs index 42c59754f..460a69c74 100644 --- a/programs/marginfi/src/state/marginfi_account.rs +++ b/programs/marginfi/src/state/marginfi_account.rs @@ -175,41 +175,58 @@ impl<'info> BankAccountWithPriceFeed<'_, 'info> { .filter(|balance| balance.active) .collect::>(); - debug!("Expecting {} remaining accounts", active_balances.len() * 2); + let expected_accounts = active_balances + .iter() + .map(|balance| { + if balance.bank_asset_tag == ASSET_TAG_STAKED { + 4 + } else { + 2 + } + }) + .sum::(); + + debug!("Expecting {} remaining accounts", expected_accounts); debug!("Got {} remaining accounts", remaining_ais.len()); check!( - active_balances.len() * 2 <= remaining_ais.len(), + expected_accounts <= remaining_ais.len(), MarginfiError::MissingPythOrBankAccount ); let clock = Clock::get()?; + let mut account_index = 0; active_balances .iter() - .enumerate() - .map(|(i, balance)| { - let bank_index = i * 2; - let oracle_ai_idx = bank_index + 1; - - let bank_ai = remaining_ais.get(bank_index).unwrap(); + .map(|balance| { + // Determine number of accounts to process for this balance + let num_accounts = if balance.bank_asset_tag == ASSET_TAG_STAKED { + 4 + } else { + 2 + }; + // Get the bank + let bank_ai = remaining_ais.get(account_index).unwrap(); check!( balance.bank_pk.eq(bank_ai.key), MarginfiError::InvalidBankAccount ); + let bank_al = AccountLoader::::try_from(bank_ai)?; + let bank = bank_al.load()?; - let price_adapter = { - let oracle_ais = &remaining_ais[oracle_ai_idx..oracle_ai_idx + 1]; - let bank_al = AccountLoader::::try_from(bank_ai)?; - let bank = bank_al.load()?; + // Get the oracle, and the LST mint and sol pool if applicable (staked only) + let oracle_ai_idx = account_index + 1; + let oracle_ais = &remaining_ais[oracle_ai_idx..oracle_ai_idx + num_accounts - 1]; - Box::new(OraclePriceFeedAdapter::try_from_bank_config( - &bank.config, - oracle_ais, - &clock, - )) - }; + let price_adapter = Box::new(OraclePriceFeedAdapter::try_from_bank_config( + &bank.config, + oracle_ais, + &clock, + )); + + account_index += num_accounts; Ok(BankAccountWithPriceFeed { bank: bank_ai.clone(), @@ -296,12 +313,6 @@ impl<'info> BankAccountWithPriceFeed<'_, 'info> { } } - if bank.config.asset_tag == ASSET_TAG_STAKED { - asset_weight = asset_weight - .checked_mul(bank.sol_appreciation_rate.into()) - .ok_or_else(math_error!())?; - } - calc_value( bank.get_asset_amount(self.balance.asset_shares.into())?, lower_price, diff --git a/programs/marginfi/src/state/price.rs b/programs/marginfi/src/state/price.rs index 14b3e1821..fad4eb5de 100644 --- a/programs/marginfi/src/state/price.rs +++ b/programs/marginfi/src/state/price.rs @@ -1,6 +1,7 @@ use std::{cell::Ref, cmp::min}; use anchor_lang::prelude::*; +use anchor_spl::token::Mint; use enum_dispatch::enum_dispatch; use fixed::types::I80F48; use pyth_sdk_solana::{state::SolanaPriceAccount, Price, PriceFeed}; @@ -73,9 +74,9 @@ pub enum OraclePriceFeedAdapter { } impl OraclePriceFeedAdapter { - pub fn try_from_bank_config( + pub fn try_from_bank_config<'info>( bank_config: &BankConfig, - ais: &[AccountInfo], + ais: &'info [AccountInfo<'info>], clock: &Clock, ) -> MarginfiResult { Self::try_from_bank_config_with_max_age( @@ -86,9 +87,9 @@ impl OraclePriceFeedAdapter { ) } - pub fn try_from_bank_config_with_max_age( + pub fn try_from_bank_config_with_max_age<'info>( bank_config: &BankConfig, - ais: &[AccountInfo], + ais: &'info [AccountInfo<'info>], clock: &Clock, max_age: u64, ) -> MarginfiResult { @@ -151,7 +152,88 @@ impl OraclePriceFeedAdapter { )) } OracleSetup::StakedWithPythPush => { - panic!("todo"); + check!(ais.len() == 3, MarginfiError::InvalidOracleAccount); + + check!( + ais[1].key == &bank_config.oracle_keys[1] + && ais[2].key == &bank_config.oracle_keys[2], + MarginfiError::InvalidOracleAccount + ); + + let lst_mint = Account::<'info, Mint>::try_from(&ais[1]).unwrap(); + let lst_supply = lst_mint.supply; + let sol_pool_balance = ais[2].lamports(); + // Note: exchange rate is `sol_pool_balance / lst_supply`, but we will do the + // division last to avoid precision loss. Division does not need to be + // decimal-adjusted because both SOL and stake positions use 9 decimals + + // Note: mainnet/staging/devnet use "push" oracles, localnet uses legacy + if cfg!(any( + feature = "mainnet-beta", + feature = "staging", + feature = "devnet" + )) { + let account_info = &ais[0]; + + check!( + account_info.owner == &pyth_solana_receiver_sdk::id(), + MarginfiError::InvalidOracleAccount + ); + + let price_feed_id = bank_config.get_pyth_push_oracle_feed_id().unwrap(); + let mut feed = PythPushOraclePriceFeed::load_checked( + account_info, + price_feed_id, + clock, + max_age, + )?; + let adjusted_price = (feed.price.price as i128) + .checked_mul(sol_pool_balance as i128) + .ok_or_else(math_error!())? + .checked_div(lst_supply as i128) + .ok_or_else(math_error!())?; + feed.price.price = adjusted_price.try_into().unwrap(); + + let adjusted_ema_price = (feed.ema_price.price as i128) + .checked_mul(sol_pool_balance as i128) + .ok_or_else(math_error!())? + .checked_div(lst_supply as i128) + .ok_or_else(math_error!())?; + feed.ema_price.price = adjusted_ema_price.try_into().unwrap(); + + let price = OraclePriceFeedAdapter::PythPushOracle(feed); + Ok(price) + } else { + // Localnet only + check!( + ais[0].key == &bank_config.oracle_keys[0], + MarginfiError::InvalidOracleAccount + ); + + let account_info = &ais[0]; + let mut feed = PythLegacyPriceFeed::load_checked( + account_info, + clock.unix_timestamp, + max_age, + )?; + + let adjusted_price = (feed.price.price as i128) + .checked_mul(sol_pool_balance as i128) + .ok_or_else(math_error!())? + .checked_div(lst_supply as i128) + .ok_or_else(math_error!())?; + feed.price.price = adjusted_price.try_into().unwrap(); + + let adjusted_ema_price = (feed.ema_price.price as i128) + .checked_mul(sol_pool_balance as i128) + .ok_or_else(math_error!())? + .checked_div(lst_supply as i128) + .ok_or_else(math_error!())?; + feed.ema_price.price = adjusted_ema_price.try_into().unwrap(); + + let price = OraclePriceFeedAdapter::PythLegacy(feed); + Ok(price) + } } } } diff --git a/tests/s04_borrow.spec.ts b/tests/s04_borrow.spec.ts index 682cc137d..d70cf4efd 100644 --- a/tests/s04_borrow.spec.ts +++ b/tests/s04_borrow.spec.ts @@ -98,6 +98,8 @@ describe("Deposit funds (included staked assets)", () => { oracles.wsolOracle.publicKey, validators[0].bank, oracles.wsolOracle.publicKey, // Note the Staked bank uses wsol oracle too + validators[0].splMint, + validators[0].splSolPool, bankKeypairUsdc.publicKey, oracles.usdcOracle.publicKey, ], diff --git a/tests/s05_solAppreciates.spec.ts b/tests/s05_solAppreciates.spec.ts index f97d54b50..36b2885c2 100644 --- a/tests/s05_solAppreciates.spec.ts +++ b/tests/s05_solAppreciates.spec.ts @@ -29,8 +29,8 @@ import { assert } from "chai"; import { borrowIx } from "./utils/user-instructions"; import { USER_ACCOUNT } from "./utils/mocks"; import { getBankrunBlockhash } from "./utils/spl-staking-utils"; -import { cacheSolExchangeRate } from "./utils/group-instructions"; import { wrappedI80F48toBigNumber } from "@mrgnlabs/mrgn-common"; +import { dumpBankrunLogs } from "./utils/tools"; describe("Borrow power grows as v0 Staked SOL gains value from appreciation", () => { const program = workspace.Marginfi as Program; @@ -56,6 +56,8 @@ describe("Borrow power grows as v0 Staked SOL gains value from appreciation", () remaining: [ validators[0].bank, oracles.wsolOracle.publicKey, + validators[0].splMint, + validators[0].splSolPool, bankKeypairSol.publicKey, oracles.wsolOracle.publicKey, ], @@ -65,6 +67,7 @@ describe("Borrow power grows as v0 Staked SOL gains value from appreciation", () tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); tx.sign(user.wallet); let result = await banksClient.tryProcessTransaction(tx); + // 6010 (Generic risk engine rejection) assertBankrunTxFailed(result, "0x177a"); @@ -75,47 +78,7 @@ describe("Borrow power grows as v0 Staked SOL gains value from appreciation", () assert.equal(balances[1].active, false); }); - // Note: there is some natural appreciation here because a few epochs have elapsed... - // TODO: Show math for expected appreciation due to epochs advancing - it("(permissionless) v0 cache stake - happy path (natural appreciation)", async () => { - let tx = new Transaction().add( - await cacheSolExchangeRate(program, { - bank: validators[0].bank, - lstMint: validators[0].splMint, - solPool: validators[0].splSolPool, - stakePool: validators[0].splPool, - }) - ); - tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); - tx.sign(wallet.payer); // provider wallet pays the tx fee - await banksClient.processTransaction(tx); - - const bank = await bankrunProgram.account.bank.fetch(validators[0].bank); - if (verbose) { - console.log( - "1 [validator 0 LST token] is now worth: " + - wrappedI80F48toBigNumber(bank.solAppreciationRate).toString() + - " SOL" - ); - } - assertI80F48Approx(bank.solAppreciationRate, 1.033, 0.01); - }); - - it("(attacker) tries to sneak a bad spl pool - should fail", async () => { - let tx = new Transaction().add( - await cacheSolExchangeRate(program, { - bank: validators[0].bank, - lstMint: validators[0].splMint, - solPool: wallet.publicKey, - stakePool: validators[0].splPool, - }) - ); - tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); - tx.sign(wallet.payer); // provider wallet pays the tx fee - let result = await banksClient.tryProcessTransaction(tx); - // 6048 (Stake pool validation failed) - assertBankrunTxFailed(result, "0x17a0"); - }); + // Note: there is also some natural appreciation here because a few epochs have elapsed... // Here we mock epoch rewards by simply minting SOL into the validator's pool without staking it("v0 stake grows by " + appreciation + " SOL", async () => { @@ -132,32 +95,64 @@ describe("Borrow power grows as v0 Staked SOL gains value from appreciation", () await banksClient.processTransaction(tx); }); - // Note: in rare instances the test will run too quickly and will fail with `This transaction has - // already been processed` because it is the same tx as the previous one (i.e. if they are signed - // for the same blockhash and end up in the same slot). You can add a small delay or simply rerun - // the test. - it("(permissionless) validator 0 cache stake - 1 LST is now worth 2 SOL", async () => { + it("(user 2 - attacker) ties to sneak in bad lst mint - should fail", async () => { + const user = users[2]; + const userAccount = user.accounts.get(USER_ACCOUNT); + let tx = new Transaction().add( + await borrowIx(program, { + marginfiGroup: marginfiGroup.publicKey, + marginfiAccount: userAccount, + authority: user.wallet.publicKey, + bank: bankKeypairSol.publicKey, + tokenAccount: user.wsolAccount, + remaining: [ + validators[0].bank, + oracles.wsolOracle.publicKey, + validators[1].splMint, // Bad mint + validators[0].splSolPool, + bankKeypairSol.publicKey, + oracles.wsolOracle.publicKey, + ], + amount: new BN(0.1 * 10 ** ecosystem.wsolDecimals), + }) + ); + tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + tx.sign(user.wallet); + let result = await banksClient.tryProcessTransaction(tx); + + // Throws 6007 (InvalidOracleAccount) first at `try_from_bank_config_with_max_age` which is + // converted to 6010 (Generic risk engine rejection) downstream + assertBankrunTxFailed(result, "0x177a"); + }); + + it("(user 2 - attacker) ties to sneak in bad sol pool - should fail", async () => { + const user = users[2]; + const userAccount = user.accounts.get(USER_ACCOUNT); let tx = new Transaction().add( - await cacheSolExchangeRate(program, { - bank: validators[0].bank, - lstMint: validators[0].splMint, - solPool: validators[0].splSolPool, - stakePool: validators[0].splPool, + await borrowIx(program, { + marginfiGroup: marginfiGroup.publicKey, + marginfiAccount: userAccount, + authority: user.wallet.publicKey, + bank: bankKeypairSol.publicKey, + tokenAccount: user.wsolAccount, + remaining: [ + validators[0].bank, + oracles.wsolOracle.publicKey, + validators[0].splMint, + validators[1].splSolPool, // Bad pool + bankKeypairSol.publicKey, + oracles.wsolOracle.publicKey, + ], + amount: new BN(0.2 * 10 ** ecosystem.wsolDecimals), }) ); tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); - tx.sign(wallet.payer); // provider wallet pays the tx fee - await banksClient.processTransaction(tx); + tx.sign(user.wallet); + let result = await banksClient.tryProcessTransaction(tx); - const bank = await bankrunProgram.account.bank.fetch(validators[0].bank); - if (verbose) { - console.log( - "1 [validator 0 LST token] is now worth: " + - wrappedI80F48toBigNumber(bank.solAppreciationRate).toString() + - " SOL" - ); - } - assertI80F48Approx(bank.solAppreciationRate, 2.033, 0.01); + // Throws 6007 (InvalidOracleAccount) first at `try_from_bank_config_with_max_age` which is + // converted to 6010 (Generic risk engine rejection) downstream + assertBankrunTxFailed(result, "0x177a"); }); // The account is now worth enough for this borrow to succeed! @@ -174,10 +169,16 @@ describe("Borrow power grows as v0 Staked SOL gains value from appreciation", () remaining: [ validators[0].bank, oracles.wsolOracle.publicKey, + validators[0].splMint, + validators[0].splSolPool, bankKeypairSol.publicKey, oracles.wsolOracle.publicKey, ], - amount: new BN(1.1 * 10 ** ecosystem.wsolDecimals), + // Note: We use a different (slightly higher) amount, so Bankrun treats this as a different + // tx. Using the exact same values as above can cause the test to fail on faster machines + // because the same tx was already sent for this blockhash (i.e. "this transaction has + // already been processed") + amount: new BN(1.111 * 10 ** ecosystem.wsolDecimals), }) ); tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); diff --git a/tests/utils/group-instructions.ts b/tests/utils/group-instructions.ts index 9a50df602..2022b7230 100644 --- a/tests/utils/group-instructions.ts +++ b/tests/utils/group-instructions.ts @@ -233,31 +233,6 @@ export const updateEmissions = ( // ************* Below this line, not yet included in package **************** -export type CacheSolExchangeRateArgs = { - bank: PublicKey; - lstMint: PublicKey; - solPool: PublicKey; - stakePool: PublicKey; -}; - -export const cacheSolExchangeRate = ( - program: Program, - args: CacheSolExchangeRateArgs -) => { - const ix = program.methods - .cacheSolExRate() - .accounts({ - bank: args.bank, - lstMint: args.lstMint, - solPool: args.solPool, - stakePool: args.stakePool, - // tokenProgram: TOKEN_PROGRAM_ID, - }) - .instruction(); - - return ix; -}; - export type InitGlobalFeeStateArgs = { payer: PublicKey; admin: PublicKey; diff --git a/tests/utils/tools.ts b/tests/utils/tools.ts index 0ddcbad6b..c73355f64 100644 --- a/tests/utils/tools.ts +++ b/tests/utils/tools.ts @@ -1,3 +1,5 @@ +import { BanksTransactionResultWithMeta } from "solana-bankrun"; + /** * Function to print bytes from a Buffer in groups with column labels and color highlighting for non-zero values * @param buffer - The Buffer to process @@ -26,9 +28,11 @@ export const printBufferGroups = ( // Function to calculate RGB color based on row index const calculateGradientColor = (startIndex) => { const maxIndex = 255 * 3; - const normalizedIndex = (startIndex % maxIndex); + const normalizedIndex = startIndex % maxIndex; - let r = 0, g = 0, b = 0; + let r = 0, + g = 0, + b = 0; if (normalizedIndex < 255) { b = 255; @@ -70,9 +74,13 @@ export const printBufferGroups = ( const label = `${i.toString().padStart(3, " ")}-${(i + groupLength - 1) .toString() .padStart(3, " ")}`; - console.log( - `${color}${label}\x1b[0m | ${group.join(" | ")}` - ); + console.log(`${color}${label}\x1b[0m | ${group.join(" | ")}`); } } }; + +export const dumpBankrunLogs = (result: BanksTransactionResultWithMeta) => { + for (let i = 0; i < result.meta.logMessages.length; i++) { + console.log(i + " " + result.meta.logMessages[i]); + } +}; From 8c5d59ba685efe04b203d98a420066b9b5b7586a Mon Sep 17 00:00:00 2001 From: jgur-psyops Date: Thu, 31 Oct 2024 15:53:17 -0400 Subject: [PATCH 43/52] Tests for propagation --- .../propagate_staked_settings.rs | 9 +- programs/marginfi/src/state/price.rs | 5 - tests/01_initGroup.spec.ts | 5 +- tests/s06_propagateSets.spec.ts | 182 ++++++++++++++++++ tests/utils/group-instructions.ts | 38 ++++ 5 files changed, 231 insertions(+), 8 deletions(-) create mode 100644 tests/s06_propagateSets.spec.ts diff --git a/programs/marginfi/src/instructions/marginfi_group/propagate_staked_settings.rs b/programs/marginfi/src/instructions/marginfi_group/propagate_staked_settings.rs index 9f1d8595e..94fc263bc 100644 --- a/programs/marginfi/src/instructions/marginfi_group/propagate_staked_settings.rs +++ b/programs/marginfi/src/instructions/marginfi_group/propagate_staked_settings.rs @@ -9,6 +9,12 @@ pub fn propagate_staked_settings(ctx: Context) -> Resul let settings = ctx.accounts.staked_settings.load()?; let mut bank = ctx.accounts.bank.load_mut()?; + // Only validate the oracle if it has changed + if settings.oracle != bank.config.oracle_keys[0] { + bank.config + .validate_staked_oracle_setup(ctx.remaining_accounts)?; + } + bank.config.oracle_keys[0] = settings.oracle; bank.config.asset_weight_init = settings.asset_weight_init; bank.config.asset_weight_maint = settings.asset_weight_maint; @@ -18,7 +24,6 @@ pub fn propagate_staked_settings(ctx: Context) -> Resul bank.config.risk_tier = settings.risk_tier; bank.config.validate()?; - bank.config.validate_staked_oracle_setup(ctx.remaining_accounts)?; // ...Possibly emit event. Ok(()) @@ -37,7 +42,7 @@ pub struct PropagateStakedSettings<'info> { mut, constraint = { let bank = bank.load()?; - bank.group == marginfi_group.key() && + bank.group == marginfi_group.key() && bank.config.asset_tag == ASSET_TAG_STAKED } )] diff --git a/programs/marginfi/src/state/price.rs b/programs/marginfi/src/state/price.rs index fad4eb5de..ebe976554 100644 --- a/programs/marginfi/src/state/price.rs +++ b/programs/marginfi/src/state/price.rs @@ -365,11 +365,6 @@ impl OraclePriceFeedAdapter { _ => err!(MarginfiError::StakePoolValidationFailed), }?; - check!( - oracle_ais[0].key == &bank_config.oracle_keys[0], - MarginfiError::InvalidOracleAccount - ); - check!(oracle_ais.len() == 1, MarginfiError::InvalidOracleAccount); // Note: mainnet/staging/devnet use push oracles, localnet uses legacy push if cfg!(any( diff --git a/tests/01_initGroup.spec.ts b/tests/01_initGroup.spec.ts index e2db8a605..ad481c582 100644 --- a/tests/01_initGroup.spec.ts +++ b/tests/01_initGroup.spec.ts @@ -153,6 +153,10 @@ describe("Init group", () => { assert.ok(failed, "Transaction succeeded when it should have failed"); }); + // Note: there are no Staked Collateral positions in the end to end test suite (those are in the + // BankRun suite e.g. s01) so these settings do nothing. Many of the these settings as also wrong + // or don't make sense (e.g. weights > 0 with isolated risk teir) and would fail at propagation + it("(admin) Edit staked settings for group", async () => { const settings: StakedSettingsEdit = { oracle: PublicKey.default, @@ -232,5 +236,4 @@ describe("Init group", () => { assert.equal(settingsAcc.oracleMaxAge, 60); }); - }); diff --git a/tests/s06_propagateSets.spec.ts b/tests/s06_propagateSets.spec.ts new file mode 100644 index 000000000..5af150a30 --- /dev/null +++ b/tests/s06_propagateSets.spec.ts @@ -0,0 +1,182 @@ +import { workspace, Program } from "@coral-xyz/anchor"; +import { PublicKey, Transaction } from "@solana/web3.js"; +import BN from "bn.js"; +import { Marginfi } from "../target/types/marginfi"; +import { + marginfiGroup, + validators, + groupAdmin, + oracles, + bankrunContext, + banksClient, + bankrunProgram, +} from "./rootHooks"; +import { + editStakedSettings, + propagateStakedSettings, +} from "./utils/group-instructions"; +import { deriveBankWithSeed, deriveStakedSettings } from "./utils/pdas"; +import { getBankrunBlockhash } from "./utils/spl-staking-utils"; +import { bigNumberToWrappedI80F48 } from "@mrgnlabs/mrgn-common"; +import { assert } from "chai"; +import { + assertKeysEqual, + assertI80F48Approx, + assertBNEqual, + assertBankrunTxFailed, +} from "./utils/genericTests"; +import { + defaultStakedInterestSettings, + StakedSettingsEdit, +} from "./utils/types"; + +describe("Edit and propagate staked settings", () => { + const program = workspace.Marginfi as Program; + + let settingsKey: PublicKey; + let bankKey: PublicKey; + + before(async () => { + [settingsKey] = deriveStakedSettings( + program.programId, + marginfiGroup.publicKey + ); + [bankKey] = deriveBankWithSeed( + program.programId, + marginfiGroup.publicKey, + validators[0].splMint, + new BN(0) + ); + }); + + it("(admin) edits some settings - happy path", async () => { + const settings: StakedSettingsEdit = { + oracle: oracles.usdcOracle.publicKey, + assetWeightInit: bigNumberToWrappedI80F48(0.2), + assetWeightMaint: bigNumberToWrappedI80F48(0.3), + depositLimit: new BN(42), + totalAssetValueInitLimit: new BN(43), + oracleMaxAge: 44, + riskTier: { + collateral: undefined, + }, + }; + let tx = new Transaction().add( + await editStakedSettings(groupAdmin.userMarginProgram, { + settingsKey: settingsKey, + settings: settings, + }) + ); + tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + tx.sign(groupAdmin.wallet); + await banksClient.processTransaction(tx); + + let settingsAcc = await bankrunProgram.account.stakedSettings.fetch( + settingsKey + ); + assertKeysEqual(settingsAcc.key, settingsKey); + assertKeysEqual(settingsAcc.oracle, oracles.usdcOracle.publicKey); + assertI80F48Approx(settingsAcc.assetWeightInit, 0.2); + assertI80F48Approx(settingsAcc.assetWeightMaint, 0.3); + assertBNEqual(settingsAcc.depositLimit, 42); + assertBNEqual(settingsAcc.totalAssetValueInitLimit, 43); + assert.equal(settingsAcc.oracleMaxAge, 44); + assert.deepEqual(settingsAcc.riskTier, { collateral: {} }); + }); + + it("(permissionless) Propagate staked settings to a bank - happy path", async () => { + let tx = new Transaction(); + tx.add( + await propagateStakedSettings(program, { + settings: settingsKey, + bank: bankKey, + oracle: oracles.usdcOracle.publicKey, + }) + ); + tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + tx.sign(groupAdmin.wallet); // just to the pay the fee + let result = await banksClient.tryProcessTransaction(tx); + + const bank = await bankrunProgram.account.bank.fetch(bankKey); + const config = bank.config; + assertKeysEqual(config.oracleKeys[0], oracles.usdcOracle.publicKey); + assertI80F48Approx(config.assetWeightInit, 0.2); + assertI80F48Approx(config.assetWeightMaint, 0.3); + assertBNEqual(config.depositLimit, 42); + assertBNEqual(config.totalAssetValueInitLimit, 43); + assert.equal(config.oracleMaxAge, 44); + assert.deepEqual(config.riskTier, { collateral: {} }); + }); + + it("(admin) sets a bad oracle - fails at propagation", async () => { + const settings: StakedSettingsEdit = { + oracle: PublicKey.default, + assetWeightInit: null, + assetWeightMaint: null, + depositLimit: null, + totalAssetValueInitLimit: null, + oracleMaxAge: null, + riskTier: null, + }; + let tx = new Transaction().add( + await editStakedSettings(groupAdmin.userMarginProgram, { + settingsKey: settingsKey, + settings: settings, + }) + ); + tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + tx.sign(groupAdmin.wallet); + await banksClient.processTransaction(tx); + + let settingsAcc = await bankrunProgram.account.stakedSettings.fetch( + settingsKey + ); + assertKeysEqual(settingsAcc.oracle, PublicKey.default); + + tx = new Transaction(); + tx.add( + await propagateStakedSettings(program, { + settings: settingsKey, + bank: bankKey, + oracle: PublicKey.default, + }) + ); + tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + tx.sign(groupAdmin.wallet); // just to the pay the fee + let result = await banksClient.tryProcessTransaction(tx); + + // 6007 (InvalidOracleAccount) + assertBankrunTxFailed(result, "0x1777"); + }); + + it("(admin) restores default settings - happy path", async () => { + const defaultSettings = defaultStakedInterestSettings( + oracles.wsolOracle.publicKey + ); + const settings: StakedSettingsEdit = { + oracle: defaultSettings.oracle, + assetWeightInit: defaultSettings.assetWeightInit, + assetWeightMaint: defaultSettings.assetWeightMaint, + depositLimit: defaultSettings.depositLimit, + totalAssetValueInitLimit: defaultSettings.totalAssetValueInitLimit, + oracleMaxAge: defaultSettings.oracleMaxAge, + riskTier: defaultSettings.riskTier, + }; + // Note you can pack propagates into the edit tx, so with a LUT you can easily propagate + // hundreds of banks in the same ts as edit + let tx = new Transaction().add( + await editStakedSettings(groupAdmin.userMarginProgram, { + settingsKey: settingsKey, + settings: settings, + }), + await propagateStakedSettings(program, { + settings: settingsKey, + bank: bankKey, + oracle: defaultSettings.oracle, + }) + ); + tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); + tx.sign(groupAdmin.wallet); + await banksClient.processTransaction(tx); + }); +}); diff --git a/tests/utils/group-instructions.ts b/tests/utils/group-instructions.ts index 2022b7230..3bf5bcb6f 100644 --- a/tests/utils/group-instructions.ts +++ b/tests/utils/group-instructions.ts @@ -294,6 +294,8 @@ export const editGlobalFeeState = ( return ix; }; +// TODO propagate fee state and test + export type InitStakedSettingsArgs = { group: PublicKey; feePayer: PublicKey; @@ -342,6 +344,42 @@ export const editStakedSettings = ( return ix; }; +/** + * oracle - required only if settings updates the oracle key + */ +export type PropagateStakedSettingsArgs = { + settings: PublicKey; + bank: PublicKey; + oracle?: PublicKey; +}; + +export const propagateStakedSettings = ( + program: Program, + args: PropagateStakedSettingsArgs +) => { + const remainingAccounts = args.oracle + ? [ + { + pubkey: args.oracle, + isSigner: false, + isWritable: false, + } as AccountMeta, + ] + : []; + + const ix = program.methods + .propagateStakedSettings() + .accounts({ + // marginfiGroup: args.group, // implied from stakedSettings + stakedSettings: args.settings, + bank: args.bank, + }) + .remainingAccounts(remainingAccounts) + .instruction(); + + return ix; +}; + export type AddBankPermissionlessArgs = { marginfiGroup: PublicKey; feePayer: PublicKey; From f711b76e08f526beb75de51d4b10334d23123540 Mon Sep 17 00:00:00 2001 From: jgur-psyops Date: Fri, 1 Nov 2024 02:24:37 -0400 Subject: [PATCH 44/52] Fix lint 1 --- .../marginfi/src/instructions/marginfi_group/add_pool.rs | 2 +- .../src/instructions/marginfi_group/add_pool_with_seed.rs | 2 +- programs/marginfi/src/state/marginfi_group.rs | 8 ++------ 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/programs/marginfi/src/instructions/marginfi_group/add_pool.rs b/programs/marginfi/src/instructions/marginfi_group/add_pool.rs index 439b127df..6da76f1c6 100644 --- a/programs/marginfi/src/instructions/marginfi_group/add_pool.rs +++ b/programs/marginfi/src/instructions/marginfi_group/add_pool.rs @@ -45,7 +45,7 @@ pub fn lending_pool_add_bank( let mut bank = bank_loader.load_init()?; check!( - !(bank_config.asset_tag == ASSET_TAG_STAKED), + bank_config.asset_tag != ASSET_TAG_STAKED, MarginfiError::AddedStakedPoolManually ); diff --git a/programs/marginfi/src/instructions/marginfi_group/add_pool_with_seed.rs b/programs/marginfi/src/instructions/marginfi_group/add_pool_with_seed.rs index 486b78ce4..3cb3d8f6a 100644 --- a/programs/marginfi/src/instructions/marginfi_group/add_pool_with_seed.rs +++ b/programs/marginfi/src/instructions/marginfi_group/add_pool_with_seed.rs @@ -45,7 +45,7 @@ pub fn lending_pool_add_bank_with_seed( let mut bank = bank_loader.load_init()?; check!( - !(bank_config.asset_tag == ASSET_TAG_STAKED), + bank_config.asset_tag != ASSET_TAG_STAKED, MarginfiError::AddedStakedPoolManually ); diff --git a/programs/marginfi/src/state/marginfi_group.rs b/programs/marginfi/src/state/marginfi_group.rs index 81bbff3aa..7144bdbd4 100644 --- a/programs/marginfi/src/state/marginfi_group.rs +++ b/programs/marginfi/src/state/marginfi_group.rs @@ -1165,8 +1165,9 @@ impl Display for BankOperationalState { } #[repr(u8)] -#[derive(Copy, Clone, Debug, AnchorSerialize, AnchorDeserialize, PartialEq, Eq)] +#[derive(Copy, Clone, Debug, AnchorSerialize, AnchorDeserialize, PartialEq, Eq, Default)] pub enum RiskTier { + #[default] Collateral = 0, /// ## Isolated Risk /// Assets in this trance can be borrowed only in isolation. @@ -1179,11 +1180,6 @@ pub enum RiskTier { unsafe impl Zeroable for RiskTier {} unsafe impl Pod for RiskTier {} -impl Default for RiskTier { - fn default() -> Self { - RiskTier::Collateral - } -} #[repr(C)] #[cfg_attr( From 259fd8372e5f795ccfc605bd78b329a1c089f29f Mon Sep 17 00:00:00 2001 From: jgur-psyops Date: Thu, 7 Nov 2024 16:31:47 -0500 Subject: [PATCH 45/52] Consolidate validation functions, remove appreciation rate --- .../marginfi_group/configure_bank.rs | 15 +- .../propagate_staked_settings.rs | 2 +- programs/marginfi/src/state/marginfi_group.rs | 26 +-- programs/marginfi/src/state/price.rs | 158 +++++++++--------- .../tests/admin_actions/setup_bank.rs | 8 +- programs/marginfi/tests/misc/regression.rs | 7 +- tests/s02_addBank.spec.ts | 1 - 7 files changed, 90 insertions(+), 127 deletions(-) diff --git a/programs/marginfi/src/instructions/marginfi_group/configure_bank.rs b/programs/marginfi/src/instructions/marginfi_group/configure_bank.rs index 9b26f2fbe..1674c3c8e 100644 --- a/programs/marginfi/src/instructions/marginfi_group/configure_bank.rs +++ b/programs/marginfi/src/instructions/marginfi_group/configure_bank.rs @@ -1,4 +1,4 @@ -use crate::constants::{ASSET_TAG_STAKED, EMISSIONS_AUTH_SEED, EMISSIONS_TOKEN_ACCOUNT_SEED}; +use crate::constants::{EMISSIONS_AUTH_SEED, EMISSIONS_TOKEN_ACCOUNT_SEED}; use crate::events::{GroupEventHeader, LendingPoolBankConfigureEvent}; use crate::prelude::MarginfiError; use crate::{check, math_error, utils}; @@ -20,13 +20,8 @@ pub fn lending_pool_configure_bank( bank.configure(&bank_config)?; if bank_config.oracle.is_some() { - if bank.config.asset_tag == ASSET_TAG_STAKED { - bank.config - .validate_staked_oracle_setup(ctx.remaining_accounts)?; - } else { - bank.config - .validate_oracle_setup(ctx.remaining_accounts, None, None, None)?; - } + bank.config + .validate_oracle_setup(ctx.remaining_accounts, None, None, None)?; } emit!(LendingPoolBankConfigureEvent { @@ -144,9 +139,11 @@ pub struct LendingPoolSetupEmissions<'info> { )] pub emissions_token_account: Box>, + /// NOTE: This is a TokenAccount, spl transfer will validate it. + /// /// CHECK: Account provided only for funding rewards #[account(mut)] - pub emissions_funding_account: AccountInfo<'info>, // TODO why isn't this TokenAccount? + pub emissions_funding_account: AccountInfo<'info>, pub token_program: Interface<'info, TokenInterface>, pub system_program: Program<'info, System>, diff --git a/programs/marginfi/src/instructions/marginfi_group/propagate_staked_settings.rs b/programs/marginfi/src/instructions/marginfi_group/propagate_staked_settings.rs index 94fc263bc..d9515bef1 100644 --- a/programs/marginfi/src/instructions/marginfi_group/propagate_staked_settings.rs +++ b/programs/marginfi/src/instructions/marginfi_group/propagate_staked_settings.rs @@ -12,7 +12,7 @@ pub fn propagate_staked_settings(ctx: Context) -> Resul // Only validate the oracle if it has changed if settings.oracle != bank.config.oracle_keys[0] { bank.config - .validate_staked_oracle_setup(ctx.remaining_accounts)?; + .validate_oracle_setup(ctx.remaining_accounts, None, None, None)?; } bank.config.oracle_keys[0] = settings.oracle; diff --git a/programs/marginfi/src/state/marginfi_group.rs b/programs/marginfi/src/state/marginfi_group.rs index 7144bdbd4..e919adffe 100644 --- a/programs/marginfi/src/state/marginfi_group.rs +++ b/programs/marginfi/src/state/marginfi_group.rs @@ -490,19 +490,10 @@ pub struct Bank { pub emissions_remaining: WrappedI80F48, pub emissions_mint: Pubkey, - /// For banks where `config.asset_tag == ASSET_TAG_STAKED`, this defines the last-cached - /// exchange rate of LST to SOL, i.e. the price appreciation of the LST. For example, if this is - /// 1, then the LST trades 1:1 for SOL. If this is 1.1, then 1 LST can be exchange for 1.1 SOL. - /// - /// Currently, this cannot be less than 1 (but this may change if slashing is implemented) - /// - /// For banks where `config.asset_tag != ASSET_TAG_STAKED` this field does nothing and may be 0, - /// 1, or any other value. - pub sol_appreciation_rate: WrappedI80F48, /// Fees collected and pending withdraw for the `FeeState.global_fee_wallet`'s cannonical ATA for `mint` pub collected_program_fees_outstanding: WrappedI80F48, - pub _padding_0: [[u64; 2]; 26], + pub _padding_0: [[u64; 2]; 27], pub _padding_1: [[u64; 2]; 32], // 16 * 2 * 32 = 1024B } @@ -549,7 +540,7 @@ impl Bank { emissions_rate: 0, emissions_remaining: I80F48::ZERO.into(), emissions_mint: Pubkey::default(), - sol_appreciation_rate: I80F48::ONE.into(), + collected_program_fees_outstanding: I80F48::ZERO.into(), ..Default::default() } } @@ -1445,7 +1436,9 @@ impl BankConfig { self.borrow_limit != u64::MAX } - /// * lst_mint, stake_pool, sol_pool - required only if configuring `OracleSetup::StakedWithPythPush` + /// * lst_mint, stake_pool, sol_pool - required only if configuring + /// `OracleSetup::StakedWithPythPush` on initial setup. If configuring a staked bank after + /// initial setup, can be omitted pub fn validate_oracle_setup( &self, ais: &[AccountInfo], @@ -1457,15 +1450,6 @@ impl BankConfig { Ok(()) } - /// Because the mint (and thus corresponding stake pool) of a staked collateral bank cannot - /// update after inception, this function validates just the oracle, ignoring the lst mint and - /// sol pool. This function works only for banks configured as StakedWithPythPush, and otherwise - /// errors - pub fn validate_staked_oracle_setup(&self, ais: &[AccountInfo]) -> MarginfiResult { - OraclePriceFeedAdapter::validate_staked_bank_config_light(self, ais)?; - Ok(()) - } - pub fn usd_init_limit_active(&self) -> bool { self.total_asset_value_init_limit != TOTAL_ASSET_VALUE_INIT_LIMIT_INACTIVE } diff --git a/programs/marginfi/src/state/price.rs b/programs/marginfi/src/state/price.rs index ebe976554..a841c799c 100644 --- a/programs/marginfi/src/state/price.rs +++ b/programs/marginfi/src/state/price.rs @@ -238,7 +238,9 @@ impl OraclePriceFeedAdapter { } } - /// * lst_mint, stake_pool, sol_pool - required only if configuring `OracleSetup::StakedWithPythPush` + /// * lst_mint, stake_pool, sol_pool - required only if configuring + /// `OracleSetup::StakedWithPythPush` initially. (subsequent validations of staked banks can + /// omit these) pub fn validate_bank_config( bank_config: &BankConfig, oracle_ais: &[AccountInfo], @@ -292,97 +294,87 @@ impl OraclePriceFeedAdapter { Ok(()) } OracleSetup::StakedWithPythPush => { - check!(oracle_ais.len() == 3, MarginfiError::InvalidOracleAccount); + if lst_mint.is_some() && stake_pool.is_some() && sol_pool.is_some() { + check!(oracle_ais.len() == 3, MarginfiError::InvalidOracleAccount); + + // Note: mainnet/staging/devnet use "push" oracles, localnet uses legacy + if cfg!(any( + feature = "mainnet-beta", + feature = "staging", + feature = "devnet" + )) { + PythPushOraclePriceFeed::check_ai_and_feed_id( + &oracle_ais[0], + bank_config.get_pyth_push_oracle_feed_id().unwrap(), + )?; + } else { + // Localnet only + check!( + oracle_ais[0].key == &bank_config.oracle_keys[0], + MarginfiError::InvalidOracleAccount + ); + + PythLegacyPriceFeed::check_ais(&oracle_ais[0])?; + } - // Note: mainnet/staging/devnet use "push" oracles, localnet uses legacy - if cfg!(any( - feature = "mainnet-beta", - feature = "staging", - feature = "devnet" - )) { - PythPushOraclePriceFeed::check_ai_and_feed_id( - &oracle_ais[0], - bank_config.get_pyth_push_oracle_feed_id().unwrap(), - )?; - } else { - // Localnet only + let lst_mint = lst_mint.unwrap(); + let stake_pool = stake_pool.unwrap(); + let sol_pool = sol_pool.unwrap(); + + let program_id = &SPL_SINGLE_POOL_ID; + let stake_pool_bytes = &stake_pool.to_bytes(); + // Validate the given stake_pool derives the same lst_mint, proving stake_pool is correct + let (exp_mint, _) = + Pubkey::find_program_address(&[b"mint", stake_pool_bytes], program_id); check!( - oracle_ais[0].key == &bank_config.oracle_keys[0], - MarginfiError::InvalidOracleAccount + exp_mint == lst_mint, + MarginfiError::StakePoolValidationFailed + ); + // Validate the now-proven stake_pool derives the given sol_pool + let (exp_pool, _) = + Pubkey::find_program_address(&[b"stake", stake_pool_bytes], program_id); + check!( + exp_pool == sol_pool.key(), + MarginfiError::StakePoolValidationFailed ); - PythLegacyPriceFeed::check_ais(&oracle_ais[0])?; - } - - check!( - lst_mint.is_some() && stake_pool.is_some() && sol_pool.is_some(), - MarginfiError::StakePoolValidationFailed - ); - let lst_mint = lst_mint.unwrap(); - let stake_pool = stake_pool.unwrap(); - let sol_pool = sol_pool.unwrap(); - - let program_id = &SPL_SINGLE_POOL_ID; - let stake_pool_bytes = &stake_pool.to_bytes(); - // Validate the given stake_pool derives the same lst_mint, proving stake_pool is correct - let (exp_mint, _) = - Pubkey::find_program_address(&[b"mint", stake_pool_bytes], program_id); - check!( - exp_mint == lst_mint, - MarginfiError::StakePoolValidationFailed - ); - // Validate the now-proven stake_pool derives the given sol_pool - let (exp_pool, _) = - Pubkey::find_program_address(&[b"stake", stake_pool_bytes], program_id); - check!( - exp_pool == sol_pool.key(), - MarginfiError::StakePoolValidationFailed - ); + // Sanity check the mint. Note: spl-single-pool uses a classic Token, never Token22 + check!( + oracle_ais[1].owner == &SPL_TOKEN_PROGRAM_ID + && oracle_ais[1].key() == lst_mint, + MarginfiError::StakePoolValidationFailed + ); + // Sanity check the pool is a native stake pool. Note: the native staking program is + // written in vanilla Solana and has no Anchor discriminator. + check!( + oracle_ais[2].owner == &NATIVE_STAKE_ID && oracle_ais[2].key() == sol_pool, + MarginfiError::StakePoolValidationFailed + ); - // Sanity check the mint. Note: spl-single-pool uses a classic Token, never Token22 - check!( - oracle_ais[1].owner == &SPL_TOKEN_PROGRAM_ID && oracle_ais[1].key() == lst_mint, - MarginfiError::StakePoolValidationFailed - ); - // Sanity check the pool is a native stake pool. Note: the native staking program is - // written in vanilla Solana and has no Anchor discriminator. - check!( - oracle_ais[2].owner == &NATIVE_STAKE_ID && oracle_ais[2].key() == sol_pool, - MarginfiError::StakePoolValidationFailed - ); + Ok(()) + } else { + // light validation (after initial setup, only the Pyth oracle needs to be validated) + check!(oracle_ais.len() == 1, MarginfiError::InvalidOracleAccount); + // Note: mainnet/staging/devnet use push oracles, localnet uses legacy push + if cfg!(any( + feature = "mainnet-beta", + feature = "staging", + feature = "devnet" + )) { + PythPushOraclePriceFeed::check_ai_and_feed_id( + &oracle_ais[0], + bank_config.get_pyth_push_oracle_feed_id().unwrap(), + )?; + } else { + // Localnet only + PythLegacyPriceFeed::check_ais(&oracle_ais[0])?; + } - Ok(()) + Ok(()) + } } } } - - pub fn validate_staked_bank_config_light( - bank_config: &BankConfig, - oracle_ais: &[AccountInfo], - ) -> MarginfiResult { - match bank_config.oracle_setup { - OracleSetup::StakedWithPythPush => Ok(()), - _ => err!(MarginfiError::StakePoolValidationFailed), - }?; - - check!(oracle_ais.len() == 1, MarginfiError::InvalidOracleAccount); - // Note: mainnet/staging/devnet use push oracles, localnet uses legacy push - if cfg!(any( - feature = "mainnet-beta", - feature = "staging", - feature = "devnet" - )) { - PythPushOraclePriceFeed::check_ai_and_feed_id( - &oracle_ais[0], - bank_config.get_pyth_push_oracle_feed_id().unwrap(), - )?; - } else { - // Localnet only - PythLegacyPriceFeed::check_ais(&oracle_ais[0])?; - } - - Ok(()) - } } #[cfg_attr(feature = "client", derive(Clone, Debug))] diff --git a/programs/marginfi/tests/admin_actions/setup_bank.rs b/programs/marginfi/tests/admin_actions/setup_bank.rs index 1fea72667..622cdbd80 100644 --- a/programs/marginfi/tests/admin_actions/setup_bank.rs +++ b/programs/marginfi/tests/admin_actions/setup_bank.rs @@ -85,7 +85,6 @@ async fn add_bank_success() -> anyhow::Result<()> { emissions_rate, emissions_remaining, emissions_mint, - sol_appreciation_rate, collected_program_fees_outstanding, _padding_0, _padding_1, @@ -116,10 +115,9 @@ async fn add_bank_success() -> anyhow::Result<()> { assert_eq!(emissions_rate, 0); assert_eq!(emissions_mint, Pubkey::new_from_array([0; 32])); assert_eq!(emissions_remaining, I80F48!(0.0).into()); - assert_eq!(sol_appreciation_rate, I80F48!(1.0).into()); assert_eq!(collected_program_fees_outstanding, I80F48!(0.0).into()); - assert_eq!(_padding_0, <[[u64; 2]; 26] as Default>::default()); + assert_eq!(_padding_0, <[[u64; 2]; 27] as Default>::default()); assert_eq!(_padding_1, <[[u64; 2]; 32] as Default>::default()); // this is the only loosely checked field @@ -222,7 +220,6 @@ async fn add_bank_with_seed_success() -> anyhow::Result<()> { emissions_rate, emissions_remaining, emissions_mint, - sol_appreciation_rate, collected_program_fees_outstanding, _padding_0, _padding_1, @@ -253,10 +250,9 @@ async fn add_bank_with_seed_success() -> anyhow::Result<()> { assert_eq!(emissions_rate, 0); assert_eq!(emissions_mint, Pubkey::new_from_array([0; 32])); assert_eq!(emissions_remaining, I80F48!(0.0).into()); - assert_eq!(sol_appreciation_rate, I80F48!(1.0).into()); assert_eq!(collected_program_fees_outstanding, I80F48!(0.0).into()); - assert_eq!(_padding_0, <[[u64; 2]; 26] as Default>::default()); + assert_eq!(_padding_0, <[[u64; 2]; 27] as Default>::default()); assert_eq!(_padding_1, <[[u64; 2]; 32] as Default>::default()); // this is the only loosely checked field diff --git a/programs/marginfi/tests/misc/regression.rs b/programs/marginfi/tests/misc/regression.rs index fbe0f4a4c..5742cecc0 100644 --- a/programs/marginfi/tests/misc/regression.rs +++ b/programs/marginfi/tests/misc/regression.rs @@ -663,18 +663,13 @@ async fn bank_field_values_reg() -> anyhow::Result<()> { bank.emissions_mint, pubkey!("2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo") ); - // legacy banks can have 0 for this field, it does nothing for banks not using ASSET_TAG_STAKED - assert_eq!( - I80F48::from(bank.sol_appreciation_rate), - I80F48::from_str("0").unwrap() - ); // Legacy banks have no program fees assert_eq!( I80F48::from(bank.collected_program_fees_outstanding), I80F48::from_str("0").unwrap() ); - assert_eq!(bank._padding_0, [[0, 0]; 26]); + assert_eq!(bank._padding_0, [[0, 0]; 27]); assert_eq!(bank._padding_1, [[0, 0]; 32]); Ok(()) diff --git a/tests/s02_addBank.spec.ts b/tests/s02_addBank.spec.ts index 2d0ab5d61..bf0214464 100644 --- a/tests/s02_addBank.spec.ts +++ b/tests/s02_addBank.spec.ts @@ -416,7 +416,6 @@ describe("Init group and add banks with asset category flags", () => { ); // Noteworthy fields assert.equal(bank.config.assetTag, ASSET_TAG_STAKED); - assertI80F48Equal(bank.solAppreciationRate, I80F48_ONE); // Standard fields const config = bank.config; From 3a36bb869ab90a55d0c0441b0cecf98f24c78e98 Mon Sep 17 00:00:00 2001 From: jgur-psyops Date: Thu, 7 Nov 2024 16:37:15 -0500 Subject: [PATCH 46/52] Macro for checking if program is a localnet build or not --- programs/marginfi/src/macros.rs | 11 +++++++++++ programs/marginfi/src/state/price.rs | 26 +++++--------------------- 2 files changed, 16 insertions(+), 21 deletions(-) diff --git a/programs/marginfi/src/macros.rs b/programs/marginfi/src/macros.rs index 593dfa2af..519907f96 100644 --- a/programs/marginfi/src/macros.rs +++ b/programs/marginfi/src/macros.rs @@ -107,3 +107,14 @@ macro_rules! assert_struct_align { static_assertions::const_assert_eq!(std::mem::align_of::<$struct>(), $align); }; } + +#[macro_export] +macro_rules! live { + () => { + cfg!(any( + feature = "mainnet-beta", + feature = "staging", + feature = "devnet" + )) + }; +} \ No newline at end of file diff --git a/programs/marginfi/src/state/price.rs b/programs/marginfi/src/state/price.rs index a841c799c..f8c1396fd 100644 --- a/programs/marginfi/src/state/price.rs +++ b/programs/marginfi/src/state/price.rs @@ -20,7 +20,7 @@ use crate::{ MIN_PYTH_PUSH_VERIFICATION_LEVEL, NATIVE_STAKE_ID, PYTH_ID, SPL_SINGLE_POOL_ID, STD_DEV_MULTIPLE, SWITCHBOARD_PULL_ID, }, - debug, math_error, + debug, live, math_error, prelude::*, }; @@ -298,11 +298,7 @@ impl OraclePriceFeedAdapter { check!(oracle_ais.len() == 3, MarginfiError::InvalidOracleAccount); // Note: mainnet/staging/devnet use "push" oracles, localnet uses legacy - if cfg!(any( - feature = "mainnet-beta", - feature = "staging", - feature = "devnet" - )) { + if live!() { PythPushOraclePriceFeed::check_ai_and_feed_id( &oracle_ais[0], bank_config.get_pyth_push_oracle_feed_id().unwrap(), @@ -356,11 +352,7 @@ impl OraclePriceFeedAdapter { // light validation (after initial setup, only the Pyth oracle needs to be validated) check!(oracle_ais.len() == 1, MarginfiError::InvalidOracleAccount); // Note: mainnet/staging/devnet use push oracles, localnet uses legacy push - if cfg!(any( - feature = "mainnet-beta", - feature = "staging", - feature = "devnet" - )) { + if live!() { PythPushOraclePriceFeed::check_ai_and_feed_id( &oracle_ais[0], bank_config.get_pyth_push_oracle_feed_id().unwrap(), @@ -388,11 +380,7 @@ impl PythLegacyPriceFeed { let price_feed = load_pyth_price_feed(ai)?; // Note: mainnet/staging/devnet use oracle age, localnet ignores oracle age - let ema_price = if cfg!(any( - feature = "mainnet-beta", - feature = "staging", - feature = "devnet" - )) { + let ema_price = if live!() { price_feed .get_ema_price_no_older_than(current_time, max_age) .ok_or(MarginfiError::StaleOracle)? @@ -400,11 +388,7 @@ impl PythLegacyPriceFeed { price_feed.get_ema_price_unchecked() }; - let price = if cfg!(any( - feature = "mainnet-beta", - feature = "staging", - feature = "devnet" - )) { + let price = if live!() { price_feed .get_price_no_older_than(current_time, max_age) .ok_or(MarginfiError::StaleOracle)? From 60e02ae58e5e1d70b2f11f966cb2d413a17ea644 Mon Sep 17 00:00:00 2001 From: jgur-psyops Date: Thu, 7 Nov 2024 16:52:27 -0500 Subject: [PATCH 47/52] Fix lint 1 --- programs/marginfi/src/macros.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/programs/marginfi/src/macros.rs b/programs/marginfi/src/macros.rs index 519907f96..e942eaa6b 100644 --- a/programs/marginfi/src/macros.rs +++ b/programs/marginfi/src/macros.rs @@ -117,4 +117,4 @@ macro_rules! live { feature = "devnet" )) }; -} \ No newline at end of file +} From e6fac42011feab7b7b0ba0ff5126941b671fd388 Mon Sep 17 00:00:00 2001 From: jgur-psyops Date: Mon, 9 Dec 2024 19:55:42 -0500 Subject: [PATCH 48/52] Various fixes --- .../marginfi_account/liquidate.rs | 20 +++++++++++++ .../marginfi_group/edit_stake_settings.rs | 2 ++ .../marginfi_group/init_staked_settings.rs | 2 ++ programs/marginfi/src/state/marginfi_group.rs | 5 +++- .../marginfi/src/state/staked_settings.rs | 26 ++++++++++++++-- programs/marginfi/src/utils.rs | 30 +++++++++++++++++++ 6 files changed, 81 insertions(+), 4 deletions(-) diff --git a/programs/marginfi/src/instructions/marginfi_account/liquidate.rs b/programs/marginfi/src/instructions/marginfi_account/liquidate.rs index aeed8d5d5..8355a529c 100644 --- a/programs/marginfi/src/instructions/marginfi_account/liquidate.rs +++ b/programs/marginfi/src/instructions/marginfi_account/liquidate.rs @@ -5,6 +5,7 @@ use crate::events::{AccountEventHeader, LendingAccountLiquidateEvent, Liquidatio use crate::state::marginfi_account::{calc_amount, calc_value, RiskEngine}; use crate::state::marginfi_group::{Bank, BankVaultType}; use crate::state::price::{OraclePriceFeedAdapter, OraclePriceType, PriceAdapter, PriceBias}; +use crate::utils::{validate_asset_tags, validate_bank_asset_tags}; use crate::{ bank_signer, constants::{LIQUIDITY_VAULT_AUTHORITY_SEED, LIQUIDITY_VAULT_SEED}, @@ -91,6 +92,25 @@ pub fn lending_account_liquidate<'info>( "Asset and liability bank cannot be the same" ); + // Liquidators must repay debts in allowed asset types. A SOL debt can be repaid in any asset. A + // Staked Collateral debt must be repaid in SOL or staked collateral. A Default asset debt can + // be repaid in any Default asset or SOL. + { + let asset_bank = ctx.accounts.asset_bank.load()?; + let liab_bank = ctx.accounts.liab_bank.load()?; + validate_bank_asset_tags(&asset_bank, &liab_bank)?; + + // Sanity check user/liquidator accounts will not contain positions with mismatching tags + // after liquidation. + // * Note: user will be repaid in liab_bank + let user_acc = ctx.accounts.liquidatee_marginfi_account.load()?; + validate_asset_tags(&liab_bank, &user_acc)?; + // * Note: Liquidator repays liab bank, and is paid in asset_bank. + let liquidator_acc = ctx.accounts.liquidator_marginfi_account.load()?; + validate_asset_tags(&liab_bank, &liquidator_acc)?; + validate_asset_tags(&asset_bank, &liquidator_acc)?; + } // release immutable borrow of asset_bank/liab_bank + liquidatee/liquidator user accounts + let LendingAccountLiquidate { liquidator_marginfi_account: liquidator_marginfi_account_loader, liquidatee_marginfi_account: liquidatee_marginfi_account_loader, diff --git a/programs/marginfi/src/instructions/marginfi_group/edit_stake_settings.rs b/programs/marginfi/src/instructions/marginfi_group/edit_stake_settings.rs index 446ca48f5..c4a6f97f1 100644 --- a/programs/marginfi/src/instructions/marginfi_group/edit_stake_settings.rs +++ b/programs/marginfi/src/instructions/marginfi_group/edit_stake_settings.rs @@ -30,6 +30,8 @@ pub fn edit_staked_settings( set_if_some!(staked_settings.oracle_max_age, settings.oracle_max_age); set_if_some!(staked_settings.risk_tier, settings.risk_tier); + staked_settings.validate()?; + Ok(()) } diff --git a/programs/marginfi/src/instructions/marginfi_group/init_staked_settings.rs b/programs/marginfi/src/instructions/marginfi_group/init_staked_settings.rs index 27907bd85..35966ccb5 100644 --- a/programs/marginfi/src/instructions/marginfi_group/init_staked_settings.rs +++ b/programs/marginfi/src/instructions/marginfi_group/init_staked_settings.rs @@ -23,6 +23,8 @@ pub fn initialize_staked_settings( settings.risk_tier, ); + staked_settings.validate()?; + Ok(()) } diff --git a/programs/marginfi/src/state/marginfi_group.rs b/programs/marginfi/src/state/marginfi_group.rs index e919adffe..08b4a66d9 100644 --- a/programs/marginfi/src/state/marginfi_group.rs +++ b/programs/marginfi/src/state/marginfi_group.rs @@ -1464,7 +1464,10 @@ impl BankConfig { } pub fn get_pyth_push_oracle_feed_id(&self) -> Option<&FeedId> { - if matches!(self.oracle_setup, OracleSetup::PythPushOracle) { + if matches!( + self.oracle_setup, + OracleSetup::PythPushOracle | OracleSetup::StakedWithPythPush + ) { let bytes: &[u8; 32] = self.oracle_keys[0].as_ref().try_into().unwrap(); Some(bytes) } else { diff --git a/programs/marginfi/src/state/staked_settings.rs b/programs/marginfi/src/state/staked_settings.rs index adfc8e032..1c50420c4 100644 --- a/programs/marginfi/src/state/staked_settings.rs +++ b/programs/marginfi/src/state/staked_settings.rs @@ -1,7 +1,8 @@ use anchor_lang::prelude::*; +use fixed::types::I80F48; use fixed_macro::types::I80F48; -use crate::{assert_struct_align, assert_struct_size}; +use crate::{assert_struct_align, assert_struct_size, check, MarginfiError, MarginfiResult}; use super::marginfi_group::{RiskTier, WrappedI80F48}; @@ -46,9 +47,7 @@ pub struct StakedSettings { impl StakedSettings { pub const LEN: usize = std::mem::size_of::(); -} -impl StakedSettings { pub fn new( key: Pubkey, marginfi_group: Pubkey, @@ -73,6 +72,27 @@ impl StakedSettings { ..Default::default() } } + + /// Same as `bank.validate()`, except that liability rates and interest rates do not exist in + /// this context (since Staked Collateral accounts cannot be borrowed against and such Banks + /// will use placeholders for those values) + pub fn validate(&self) -> MarginfiResult { + let asset_init_w = I80F48::from(self.asset_weight_init); + let asset_maint_w = I80F48::from(self.asset_weight_maint); + + check!( + asset_init_w >= I80F48::ZERO && asset_init_w <= I80F48::ONE, + MarginfiError::InvalidConfig + ); + check!(asset_maint_w >= asset_init_w, MarginfiError::InvalidConfig); + + if self.risk_tier == RiskTier::Isolated { + check!(asset_init_w == I80F48::ZERO, MarginfiError::InvalidConfig); + check!(asset_maint_w == I80F48::ZERO, MarginfiError::InvalidConfig); + } + + Ok(()) + } } impl Default for StakedSettings { diff --git a/programs/marginfi/src/utils.rs b/programs/marginfi/src/utils.rs index 0cccacfe6..f9fd9ba0e 100644 --- a/programs/marginfi/src/utils.rs +++ b/programs/marginfi/src/utils.rs @@ -226,3 +226,33 @@ pub fn validate_asset_tags(bank: &Bank, marginfi_account: &MarginfiAccount) -> M Ok(()) } + +/// Validate that two banks are compatible based on their asset tags. See the following combinations +/// (* is wildcard, e.g. any tag): +/// +/// Allowed: +/// 1) Default/Default +/// 2) Sol/* +/// 3) Staked/Staked +/// +/// Forbidden: +/// 1) Default/Staked +/// +/// Returns an error if the two banks have mismatching asset tags according to the above. +pub fn validate_bank_asset_tags(bank_a: &Bank, bank_b: &Bank) -> MarginfiResult { + let is_bank_a_default = bank_a.config.asset_tag == ASSET_TAG_DEFAULT; + let is_bank_a_staked = bank_a.config.asset_tag == ASSET_TAG_STAKED; + let is_bank_b_default = bank_b.config.asset_tag == ASSET_TAG_DEFAULT; + let is_bank_b_staked = bank_b.config.asset_tag == ASSET_TAG_STAKED; + // Note: Sol is compatible with all other tags and doesn't matter... + + // 1. Default assets cannot mix with Staked assets + if is_bank_a_default && is_bank_b_staked { + return err!(MarginfiError::AssetTagMismatch); + } + if is_bank_a_staked && is_bank_b_default { + return err!(MarginfiError::AssetTagMismatch); + } + + Ok(()) +} From d6e7a1a257cd0f90f7239b94cdcbe3152757711d Mon Sep 17 00:00:00 2001 From: jgur-psyops Date: Mon, 9 Dec 2024 20:03:26 -0500 Subject: [PATCH 49/52] Add test for fix --- tests/01_initGroup.spec.ts | 53 ++++++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/tests/01_initGroup.spec.ts b/tests/01_initGroup.spec.ts index ad481c582..05537f8d1 100644 --- a/tests/01_initGroup.spec.ts +++ b/tests/01_initGroup.spec.ts @@ -154,8 +154,7 @@ describe("Init group", () => { }); // Note: there are no Staked Collateral positions in the end to end test suite (those are in the - // BankRun suite e.g. s01) so these settings do nothing. Many of the these settings as also wrong - // or don't make sense (e.g. weights > 0 with isolated risk teir) and would fail at propagation + // BankRun suite e.g. s01) so these settings do nothing. it("(admin) Edit staked settings for group", async () => { const settings: StakedSettingsEdit = { @@ -166,7 +165,7 @@ describe("Init group", () => { totalAssetValueInitLimit: new BN(43), oracleMaxAge: 44, riskTier: { - isolated: undefined, + collateral: undefined, }, }; const [settingsKey] = deriveStakedSettings( @@ -195,7 +194,7 @@ describe("Init group", () => { assertBNEqual(settingsAcc.depositLimit, 42); assertBNEqual(settingsAcc.totalAssetValueInitLimit, 43); assert.equal(settingsAcc.oracleMaxAge, 44); - assert.deepEqual(settingsAcc.riskTier, { isolated: {} }); + assert.deepEqual(settingsAcc.riskTier, { collateral: {} }); // no change }); it("(admin) Partial settings update", async () => { @@ -206,9 +205,7 @@ describe("Init group", () => { depositLimit: null, totalAssetValueInitLimit: null, oracleMaxAge: 60, - riskTier: { - isolated: undefined, - }, + riskTier: null, }; const [settingsKey] = deriveStakedSettings( program.programId, @@ -232,8 +229,48 @@ describe("Init group", () => { assertI80F48Approx(settingsAcc.assetWeightMaint, 0.3); assertBNEqual(settingsAcc.depositLimit, 42); assertBNEqual(settingsAcc.totalAssetValueInitLimit, 43); - assert.deepEqual(settingsAcc.riskTier, { isolated: {} }); + assert.deepEqual(settingsAcc.riskTier, { collateral: {} }); assert.equal(settingsAcc.oracleMaxAge, 60); }); + + // Note: Isolated riskTier requires the weights to be zero, so this is invalid... + it("(admin) Bad settings update - should fail", async () => { + const settings: StakedSettingsEdit = { + oracle: null, + assetWeightInit: null, + assetWeightMaint: null, + depositLimit: null, + totalAssetValueInitLimit: null, + oracleMaxAge: 60, + riskTier: { + isolated: undefined, + }, + }; + const [settingsKey] = deriveStakedSettings( + program.programId, + marginfiGroup.publicKey + ); + + let failed = false; + try { + await groupAdmin.userMarginProgram.provider.sendAndConfirm( + new Transaction().add( + await editStakedSettings(groupAdmin.userMarginProgram, { + settingsKey: settingsKey, + settings: settings, + }) + ) + ); + } catch (err) { + // TODO create a util for this that fails with more detail + assert.ok( + err.logs.some((log: string) => + log.includes("Error Code: InvalidConfig") + ) + ); + failed = true; + } + assert.ok(failed, "Transaction succeeded when it should have failed"); + }); }); From 85b3cd084f13d6853ee0fd4e7dc03a25e61ac8aa Mon Sep 17 00:00:00 2001 From: jgur-psyops Date: Tue, 10 Dec 2024 02:19:45 -0500 Subject: [PATCH 50/52] Fix CI pipeline 1 --- .github/actions/setup-common/action.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup-common/action.yaml b/.github/actions/setup-common/action.yaml index 76abe554f..35369af21 100644 --- a/.github/actions/setup-common/action.yaml +++ b/.github/actions/setup-common/action.yaml @@ -18,5 +18,5 @@ runs: components: rustfmt, clippy default: true - - run: (cargo install cargo-nextest || true) + - run: cargo install cargo-nextest --locked shell: bash From de33b6576215a81a742369e58c470ca55b357bb1 Mon Sep 17 00:00:00 2001 From: jgur-psyops Date: Mon, 23 Dec 2024 14:33:04 -0500 Subject: [PATCH 51/52] Reconcile merge with main 1 --- .../marginfi/src/instructions/marginfi_group/configure_bank.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/programs/marginfi/src/instructions/marginfi_group/configure_bank.rs b/programs/marginfi/src/instructions/marginfi_group/configure_bank.rs index 7014b53e7..8891ecbf9 100644 --- a/programs/marginfi/src/instructions/marginfi_group/configure_bank.rs +++ b/programs/marginfi/src/instructions/marginfi_group/configure_bank.rs @@ -38,7 +38,7 @@ pub fn lending_pool_configure_bank( bank.configure(&bank_config)?; if bank_config.oracle.is_some() { - bank.config.validate_oracle_setup(ctx.remaining_accounts)?; + bank.config.validate_oracle_setup(ctx.remaining_accounts, None, None, None)?; } emit!(LendingPoolBankConfigureEvent { From 7d5fe37c5df144d7c0f5d3f4da2cb9e9720af7dd Mon Sep 17 00:00:00 2001 From: jgur-psyops Date: Mon, 23 Dec 2024 17:01:36 -0500 Subject: [PATCH 52/52] Reconcile merge with main 2 --- programs/marginfi/src/errors.rs | 12 ++++++------ .../instructions/marginfi_group/configure_bank.rs | 3 ++- tests/01_initGroup.spec.ts | 2 +- tests/s02_addBank.spec.ts | 11 +++++++---- tests/s03_deposit.spec.ts | 11 +++++------ tests/s04_borrow.spec.ts | 3 ++- tests/utils/types.ts | 2 +- 7 files changed, 24 insertions(+), 20 deletions(-) diff --git a/programs/marginfi/src/errors.rs b/programs/marginfi/src/errors.rs index 18a8a4911..a12acc9c5 100644 --- a/programs/marginfi/src/errors.rs +++ b/programs/marginfi/src/errors.rs @@ -96,14 +96,14 @@ pub enum MarginfiError { IllegalAction, #[msg("Token22 Banks require mint account as first remaining account")] // 6046 T22MintRequired, - #[msg("Staked SOL accounts can only deposit staked assets and borrow SOL")] // 6047 - AssetTagMismatch, - #[msg("Stake pool validation failed: check the stake pool, mint, or sol pool")] // 6048 - StakePoolValidationFailed, - #[msg("Invalid ATA for global fee account")] // 6049 + #[msg("Invalid ATA for global fee account")] // 6047 InvalidFeeAta, - #[msg("Use add pool permissionless instead")] // 6050 + #[msg("Use add pool permissionless instead")] // 6048 AddedStakedPoolManually, + #[msg("Staked SOL accounts can only deposit staked assets and borrow SOL")] // 6049 + AssetTagMismatch, + #[msg("Stake pool validation failed: check the stake pool, mint, or sol pool")] // 6050 + StakePoolValidationFailed, } impl From for ProgramError { diff --git a/programs/marginfi/src/instructions/marginfi_group/configure_bank.rs b/programs/marginfi/src/instructions/marginfi_group/configure_bank.rs index 8891ecbf9..dd6ad5492 100644 --- a/programs/marginfi/src/instructions/marginfi_group/configure_bank.rs +++ b/programs/marginfi/src/instructions/marginfi_group/configure_bank.rs @@ -38,7 +38,8 @@ pub fn lending_pool_configure_bank( bank.configure(&bank_config)?; if bank_config.oracle.is_some() { - bank.config.validate_oracle_setup(ctx.remaining_accounts, None, None, None)?; + bank.config + .validate_oracle_setup(ctx.remaining_accounts, None, None, None)?; } emit!(LendingPoolBankConfigureEvent { diff --git a/tests/01_initGroup.spec.ts b/tests/01_initGroup.spec.ts index 16171b6d3..d53624f74 100644 --- a/tests/01_initGroup.spec.ts +++ b/tests/01_initGroup.spec.ts @@ -115,7 +115,7 @@ describe("Init group", () => { assertI80F48Approx(settingsAcc.assetWeightMaint, 0.9); assertBNEqual(settingsAcc.depositLimit, 1_000_000_000_000); assertBNEqual(settingsAcc.totalAssetValueInitLimit, 150_000_000); - assert.equal(settingsAcc.oracleMaxAge, 10); + assert.equal(settingsAcc.oracleMaxAge, 60); assert.deepEqual(settingsAcc.riskTier, { collateral: {} }); }); diff --git a/tests/s02_addBank.spec.ts b/tests/s02_addBank.spec.ts index 84cbb53cc..5e87cc19f 100644 --- a/tests/s02_addBank.spec.ts +++ b/tests/s02_addBank.spec.ts @@ -114,7 +114,7 @@ describe("Init group and add banks with asset category flags", () => { assertI80F48Approx(settingsAcc.assetWeightMaint, 0.9); assertBNEqual(settingsAcc.depositLimit, 1_000_000_000_000); assertBNEqual(settingsAcc.totalAssetValueInitLimit, 150_000_000); - assert.equal(settingsAcc.oracleMaxAge, 10); + assert.equal(settingsAcc.oracleMaxAge, 60); assert.deepEqual(settingsAcc.riskTier, { collateral: {} }); }); @@ -193,7 +193,8 @@ describe("Init group and add banks with asset category flags", () => { tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); tx.sign(groupAdmin.wallet, bankKeypair); let result = await banksClient.tryProcessTransaction(tx); - assertBankrunTxFailed(result, "0x17a2"); + // AddedStakedPoolManually + assertBankrunTxFailed(result, "0x17a0"); }); it("(attacker) Add bank (validator 0) with bad accounts + bad metadata - should fail", async () => { @@ -270,7 +271,8 @@ describe("Init group and add banks with asset category flags", () => { tx.sign(users[0].wallet); let result = await banksClient.tryProcessTransaction(tx); - assertBankrunTxFailed(result, "0x17a0"); + // StakePoolValidationFailed + assertBankrunTxFailed(result, "0x17a2"); } } } @@ -336,7 +338,8 @@ describe("Init group and add banks with asset category flags", () => { tx.sign(users[0].wallet); let result = await banksClient.tryProcessTransaction(tx); - assertBankrunTxFailed(result, "0x17a0"); + // StakePoolValidationFailed + assertBankrunTxFailed(result, "0x17a2"); } } diff --git a/tests/s03_deposit.spec.ts b/tests/s03_deposit.spec.ts index cc8a5601c..35daf6a04 100644 --- a/tests/s03_deposit.spec.ts +++ b/tests/s03_deposit.spec.ts @@ -19,10 +19,7 @@ import { users, validators, } from "./rootHooks"; -import { - assertBankrunTxFailed, - assertKeysEqual, -} from "./utils/genericTests"; +import { assertBankrunTxFailed, assertKeysEqual } from "./utils/genericTests"; import { assert } from "chai"; import { accountInit, depositIx } from "./utils/user-instructions"; import { LST_ATA, USER_ACCOUNT } from "./utils/mocks"; @@ -129,7 +126,8 @@ describe("Deposit funds (included staked assets)", () => { tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); tx.sign(user.wallet); let result = await banksClient.tryProcessTransaction(tx); - assertBankrunTxFailed(result, "0x179f"); + // AssetTagMismatch + assertBankrunTxFailed(result, "0x17a1"); // Verify the deposit failed and the entry does not exist const userAcc = await bankrunProgram.account.marginfiAccount.fetch( @@ -214,7 +212,8 @@ describe("Deposit funds (included staked assets)", () => { tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); tx.sign(user.wallet); let result = await banksClient.tryProcessTransaction(tx); - assertBankrunTxFailed(result, "0x179f"); + // AssetTagMismatch + assertBankrunTxFailed(result, "0x17a1"); // Verify the deposit failed and the entry does not exist const userAcc = await bankrunProgram.account.marginfiAccount.fetch( diff --git a/tests/s04_borrow.spec.ts b/tests/s04_borrow.spec.ts index d70cf4efd..20852085f 100644 --- a/tests/s04_borrow.spec.ts +++ b/tests/s04_borrow.spec.ts @@ -109,7 +109,8 @@ describe("Deposit funds (included staked assets)", () => { tx.recentBlockhash = await getBankrunBlockhash(bankrunContext); tx.sign(user.wallet); let result = await banksClient.tryProcessTransaction(tx); - assertBankrunTxFailed(result, "0x179f"); + // AssetTagMismatch + assertBankrunTxFailed(result, "0x17a1"); // Verify the deposit worked and the entry does not exist const userAcc = await bankrunProgram.account.marginfiAccount.fetch( diff --git a/tests/utils/types.ts b/tests/utils/types.ts index 99744e682..0b2b514fa 100644 --- a/tests/utils/types.ts +++ b/tests/utils/types.ts @@ -172,7 +172,7 @@ export const defaultStakedInterestSettings = (oracle: PublicKey) => { assetWeightMaint: bigNumberToWrappedI80F48(0.9), depositLimit: new BN(1_000_000_000_000), // 1000 SOL totalAssetValueInitLimit: new BN(150_000_000), - oracleMaxAge: 10, + oracleMaxAge: 60, riskTier: { collateral: undefined, },