import * as anchor from '@coral-xyz/anchor'; import { AnchorProvider, BN, Program } from '@coral-xyz/anchor'; import NodeWallet from '@coral-xyz/anchor/dist/cjs/nodewallet'; import * as spl from '@solana/spl-token'; import { Connection, LAMPORTS_PER_SOL, PublicKey, Keypair, } from '@solana/web3.js'; import { MangoV4 } from '../target/types/mango_v4'; import ProgramKeypair from '../target/deploy/mango_v4-keypair.json'; import { Group, MangoAccount, MangoClient, StubOracle, U64_MAX_BN, } from '../ts/client/src/index'; import { assert } from 'chai'; import { PerpMarketIndex, PerpOrderSide, PerpOrderType, } from '../ts/client/src/accounts/perp'; enum MINTS { USDC = 'USDC', BTC = 'BTC', } const NUM_USERS = 4; const PROGRAM_ID = '4MangoMjqJ2firMokCjjGgoK8d4MXcrgL7XJaL3w6fVg'; interface TestUser { keypair: anchor.web3.Keypair; tokenAccounts: spl.AccountInfo[]; mangoAccount: MangoAccount; client: MangoClient; } async function createMints( program: anchor.Program, payer: anchor.web3.Keypair, admin, ): Promise>> { const mints: spl.Token[] = []; for (let i = 0; i < 2; i++) { mints.push( await spl.Token.createMint( program.provider.connection, payer, admin.publicKey, admin.publicKey, 6, spl.TOKEN_PROGRAM_ID, ), ); } const mintsMap = { USDC: mints[0], BTC: mints[1], }; return mintsMap; } async function createUsers( mintsMap: Partial>, payer: anchor.web3.Keypair, provider: anchor.Provider, group: Group, connection: Connection, programId: PublicKey, ): Promise { const users: TestUser[] = []; for (let i = 0; i < NUM_USERS; i++) { const user = anchor.web3.Keypair.generate(); await provider.connection.requestAirdrop( user.publicKey, LAMPORTS_PER_SOL * 1000, ); const tokenAccounts: spl.AccountInfo[] = []; for (const mintKey in mintsMap) { const mint: spl.Token = mintsMap[mintKey]; const tokenAccount = await mint.getOrCreateAssociatedAccountInfo( user.publicKey, ); await mint.mintTo(tokenAccount.address, payer, [], 1_000_000_000_000_000); tokenAccounts.push(tokenAccount); } const client = await MangoClient.connect( new anchor.AnchorProvider( connection, new NodeWallet(user), AnchorProvider.defaultOptions(), ), 'devnet', programId, { idsSource: 'get-program-accounts' }, ); const mangoAccount = await client.getOrCreateMangoAccount(group); await mangoAccount!.reload(client); console.log('created user ' + i); users.push({ keypair: user, tokenAccounts: tokenAccounts, client, mangoAccount: mangoAccount!, }); } return users; } describe('mango-v4', () => { const programId = new PublicKey(PROGRAM_ID); // Configure the client to use the local cluster. const envProvider = anchor.AnchorProvider.env(); anchor.setProvider(envProvider); const envProviderWallet = envProvider.wallet; const envProviderPayer = (envProviderWallet as NodeWallet).payer; const options = AnchorProvider.defaultOptions(); const connection = new Connection( process.env.ANCHOR_PROVIDER_URL!, options.commitment, ); const program = anchor.workspace.MangoV4 as Program; let users: TestUser[] = []; let mintsMap: Partial>; let group: Group; let usdcOracle: StubOracle; let btcOracle: StubOracle; let envClient: MangoClient; it('Initialize group and users', async () => { console.log(`provider ${envProviderWallet.publicKey.toString()}`); console.log(`program id ${programId.toString()}`); mintsMap = await createMints(program, envProviderPayer, envProviderWallet); const groupNum = 0; const insuranceMintPk = mintsMap['USDC']!.publicKey; const adminPk = envProviderWallet.publicKey; // Passing devnet as the cluster here - client cannot accept localnet // I think this is only for getting the serum market though? envClient = await MangoClient.connect(envProvider, 'devnet', programId, { idsSource: 'get-program-accounts', }); await envClient.groupCreate(groupNum, true, 1, insuranceMintPk); group = await envClient.getGroupForCreator(adminPk, groupNum); users = await createUsers( mintsMap, envProviderPayer, envProvider, group, connection, programId, ); assert.strictEqual(group.groupNum, groupNum); assert.deepEqual(group.admin, adminPk); }); it('Create stub oracles and register tokens', async () => { await envClient.stubOracleCreate(group, mintsMap['USDC']!.publicKey, 1.0); usdcOracle = ( await envClient.getStubOracle(group, mintsMap['USDC']!.publicKey) )[0]; await envClient.tokenRegister( group, mintsMap['USDC']!.publicKey, usdcOracle.publicKey, 0.1, 0, // tokenIndex 'USDC', 0.01, 0.4, 0.07, 0.8, 0.9, 1.5, 0.0005, 1.5, 0.8, 0.6, 1.2, 1.4, 0.02, ); await group.reloadAll(envClient); await envClient.stubOracleCreate(group, mintsMap['BTC']!.publicKey, 100.0); btcOracle = ( await envClient.getStubOracle(group, mintsMap['BTC']!.publicKey) )[0]; await envClient.tokenRegister( group, mintsMap['BTC']!.publicKey, btcOracle.publicKey, 0.1, 1, // tokenIndex 'BTC', 0.01, 0.4, 0.07, 0.8, 0.9, 0.88, 0.0005, 1.5, 0.8, 0.6, 1.2, 1.4, 0.02, ); await group.reloadAll(envClient); const banks = await envClient.getBanksForGroup(group); assert.equal(banks.length, 2, 'Two banks present'); assert.equal(banks[0].name, 'USDC', 'USDC bank present'); assert.equal(banks[0].tokenIndex, 0, 'USDC bank token index set'); assert.equal(banks[0].uiDeposits(), 0, 'USDC bank has zero deposits'); assert.equal(banks[0].uiBorrows(), 0, 'USDC bank has zero borrows'); assert.equal(banks[1].name, 'BTC', 'BTC bank present'); assert.equal(banks[1].tokenIndex, 1, 'BTC bank token index set'); assert.equal(banks[1].uiDeposits(), 0, 'BTC bank has zero deposits'); assert.equal(banks[1].uiBorrows(), 0, 'BTC bank has zero borrows'); }); it('Create perp market', async () => { await envClient.perpCreateMarket( group, btcOracle.publicKey, 0, 'BTC-PERP', 0.1, 6, 10, 100, 0.975, 0.95, 1.025, 1.05, 0.012, 0.0002, 0.0, 0, 0.05, 0.05, 100, true, true, 1_000_000, 0, 0, 0, ); await group.reloadAll(envClient); const perps = await envClient.perpGetMarkets(group); assert.equal(perps.length, 1, 'One perp market present'); assert.equal(perps[0].name, 'BTC-PERP', 'Name is correct'); assert.equal(perps[0].perpMarketIndex, 0, 'Index is correct'); assert.equal( perps[0].oracle.toBase58(), btcOracle.publicKey.toBase58(), 'Oracle is correct', ); }); it('Edit perp market', async () => { let btcPerp = (await envClient.perpGetMarkets(group))[0]; assert.closeTo( btcPerp.makerFee.toNumber(), 0.0002, 0.00001, 'Maker fee before', ); assert.closeTo( btcPerp.takerFee.toNumber(), 0.0, 0.00001, 'Taker fee before', ); await envClient.perpEditMarket( group, btcPerp.perpMarketIndex, btcPerp.oracle, 0.1, btcPerp.baseDecimals, btcPerp.maintAssetWeight.toNumber(), btcPerp.initAssetWeight.toNumber(), btcPerp.maintLiabWeight.toNumber(), btcPerp.initLiabWeight.toNumber(), btcPerp.liquidationFee.toNumber(), 0.0004, // Changed 0.0005, // Changed 0.0, btcPerp.minFunding.toNumber(), btcPerp.maxFunding.toNumber(), btcPerp.impactQuantity.toNumber(), true, true, 1_000_000, 0, 0, ); await group.reloadAll(envClient); btcPerp = (await envClient.perpGetMarkets(group))[0]; assert.closeTo( btcPerp.makerFee.toNumber(), 0.0004, 0.00001, 'Maker fee changed', ); assert.closeTo( btcPerp.takerFee.toNumber(), 0.0005, 0.00001, 'Taker fee changed', ); }); it('Deposit & Withdraw tokens', async () => { const client = await MangoClient.connect( new anchor.AnchorProvider( connection, new NodeWallet(users[0].keypair), options, ), 'devnet', programId, { idsSource: 'get-program-accounts' }, ); const mangoAccount = await client.getOrCreateMangoAccount(group); await mangoAccount!.reload(client); await client.tokenDeposit( group, mangoAccount!, mintsMap.USDC!.publicKey, 100.5, ); await mangoAccount!.reload(client); await client.tokenDeposit( group, mangoAccount!, mintsMap.BTC!.publicKey, 50.5, ); await mangoAccount!.reload(client); const banks = await envClient.getBanksForGroup(group); assert.equal( mangoAccount!.getTokenBalanceUi(banks[0]), 100.5, 'USDC balance reflects deposit', ); assert.equal( mangoAccount!.getTokenBalanceUi(banks[1]), 50.5, 'BTC balance reflects deposit', ); await client.tokenWithdraw( group, mangoAccount!, mintsMap.USDC!.publicKey, 100, false, ); await mangoAccount!.reload(client); await client.tokenWithdraw( group, mangoAccount!, mintsMap.BTC!.publicKey, 50, false, ); await mangoAccount!.reload(client); assert.equal( mangoAccount!.getTokenBalanceUi(banks[0]), 0.5, 'USDC balance reflects withdrawal', ); assert.equal( mangoAccount!.getTokenBalanceUi(banks[1]), 0.5, 'BTC balance reflects withdrawal', ); }); it('Place, cancel & match perp orders', async () => { const makerClient = users[2].client; const takerClient = users[3].client; const makerAccount = users[2].mangoAccount; const takerAccount = users[3].mangoAccount; await makerAccount.reload(makerClient); await takerAccount.reload(takerClient); // Expand each account to allow for perps await makerClient.expandMangoAccount(group, makerAccount, 8, 8, 8, 8); await takerClient.expandMangoAccount(group, takerAccount, 8, 8, 8, 8); // Give each client some cash await makerClient.tokenDeposit( group, makerAccount, mintsMap.USDC!.publicKey, 1000, ); await makerAccount.reload(makerClient); await takerClient.tokenDeposit( group, takerAccount, mintsMap.USDC!.publicKey, 1000, ); await takerAccount.reload(takerClient); // Set price await envClient.stubOracleSet(group, btcOracle.publicKey, 100); btcOracle = ( await envClient.getStubOracle(group, mintsMap['BTC']!.publicKey) )[0]; assert.equal( btcOracle.price.toNumber(), 100.0, 'Oracle price has been set', ); // Maker places passive order await makerClient.perpPlaceOrder( group, makerAccount, 0 as PerpMarketIndex, PerpOrderSide.bid, 99.0, 2.0, 100_000, 0, PerpOrderType.limit, 0, 20, ); await makerClient.perpConsumeAllEvents(group, 0 as PerpMarketIndex); await makerAccount.reload(makerClient); let makerOrders = await makerAccount.loadPerpOpenOrdersForMarket( makerClient, group, 0 as PerpMarketIndex, ); assert.equal(makerOrders.length, 1, 'Maker has one open order'); assert.equal(makerOrders[0].uiPrice, 99.0, 'Price correct'); assert.equal(makerOrders[0].uiSize, 2.0, 'Size correct'); assert.equal(makerOrders[0].side, PerpOrderSide.bid, 'Side correct'); // Partial fill by taker await takerClient.perpPlaceOrder( group, takerAccount, 0 as PerpMarketIndex, PerpOrderSide.ask, 98.0, 1.0, 100_000, 0, PerpOrderType.limit, 0, 20, ); await takerClient.perpConsumeAllEvents(group, 0 as PerpMarketIndex); await takerAccount.reload(takerClient); await makerAccount.reload(takerClient); makerOrders = await makerAccount.loadPerpOpenOrdersForMarket( makerClient, group, 0 as PerpMarketIndex, ); assert.equal(makerOrders.length, 1, 'Maker still has one open order'); assert.equal(makerOrders[0].uiSize, 1.0, 'Size reduced'); const makerPerps = makerAccount.perpActive(); assert.equal(makerPerps.length, 1, 'Maker has perp position'); assert.equal(makerPerps[0].marketIndex, 0, 'Market index matches'); assert.isTrue( makerPerps[0].basePositionLots.eq(new anchor.BN(10000)), 'base position correct', ); const takerPerps = takerAccount.perpActive(); assert.equal(takerPerps.length, 1, 'Taker has perp position'); assert.equal(takerPerps[0].marketIndex, 0, 'Market index matches'); assert.isTrue( takerPerps[0].basePositionLots.eq(new anchor.BN(-10000)), 'base position correct', ); // Cancel remaining order await makerClient.perpCancelAllOrders( group, makerAccount, 0 as PerpMarketIndex, 20, ); makerOrders = await makerAccount.loadPerpOpenOrdersForMarket( makerClient, group, 0 as PerpMarketIndex, ); assert.equal(makerOrders.length, 0, 'Maker orders have been canceled'); }); it('Test perp settle pnl & settle fees', async () => { const settlerClient = users[1].client; const makerClient = users[2].client; const takerClient = users[3].client; const settlerAccount = users[1].mangoAccount; const makerAccount = users[2].mangoAccount; const takerAccount = users[3].mangoAccount; await settlerAccount.reload(settlerClient); await makerAccount.reload(makerClient); await takerAccount.reload(takerClient); // Set higher price await envClient.stubOracleSet(group, btcOracle.publicKey, 200); btcOracle = ( await envClient.getStubOracle(group, mintsMap['BTC']!.publicKey) )[0]; assert.equal( btcOracle.price.toNumber(), 200.0, 'Oracle price has been set', ); const banks = await envClient.getBanksForGroup(group); await makerAccount.reload(makerClient); await takerAccount.reload(takerClient); assert.equal( makerAccount.getTokenBalanceUi(banks[0]), 1000, 'Maker balance before', ); assert.equal( takerAccount.getTokenBalanceUi(banks[0]), 1000, 'Taker balance before', ); // Settle the PnL. Taker account is short, so it is unprofitable await settlerClient.perpSettlePnl( group, makerAccount, takerAccount, settlerAccount, 0 as PerpMarketIndex, ); await makerAccount.reload(makerClient); await takerAccount.reload(takerClient); await settlerAccount.reload(settlerClient); assert.closeTo( makerAccount.getTokenBalanceUi(banks[0]), 1100, 1, 'Maker balance has profit', ); assert.closeTo( takerAccount.getTokenBalanceUi(banks[0]), 900, 1, 'Taker balance has loss', ); assert.closeTo( settlerAccount.getTokenBalanceUi(banks[0]), 1, 0.001, 'Settler balance has extra fee', ); // Set even higher price await envClient.stubOracleSet(group, btcOracle.publicKey, 250); // Settle the fees let btcPerp = (await envClient.perpGetMarkets(group))[0]; assert.closeTo( btcPerp.feesAccrued.toNumber(), 89100, 0.1, 'Fees accrued by contract', ); assert.equal( btcPerp.feesSettled.toNumber(), 0, 'Fees settled by contract is 0', ); await envClient.perpSettleFees( group, takerAccount, 0 as PerpMarketIndex, U64_MAX_BN, ); btcPerp = (await envClient.perpGetMarkets(group))[0]; assert.equal( btcPerp.feesAccrued.toNumber(), 0, 'Fees accrued by contract is 0', ); assert.closeTo( btcPerp.feesSettled.toNumber(), 89100, 0.1, 'Fees accrued by contract have been settled', ); await takerAccount.reload(takerClient); assert.closeTo( takerAccount.getTokenBalanceUi(banks[0]), 898.95, 0.01, 'Taker balance has more loss', ); }); it('liquidate token with token', async () => { const clientA = users[0].client; const clientB = users[1].client; const mangoAccountA = users[0].mangoAccount; const mangoAccountB = users[1].mangoAccount; await mangoAccountA!.reload(clientA); await mangoAccountB!.reload(clientB); await envClient.stubOracleSet(group, btcOracle.publicKey, 100); // Initialize liquidator await clientA.tokenDeposit( group, mangoAccountA!, mintsMap.USDC!.publicKey, 1000, ); await clientA.tokenDeposit( group, mangoAccountA!, mintsMap.BTC!.publicKey, 100, ); // Deposit collateral await clientB.tokenDeposit( group, mangoAccountB!, mintsMap.BTC!.publicKey, 100, ); await mangoAccountB!.reload(clientB); // Borrow await clientB.tokenWithdraw( group, mangoAccountB!, mintsMap.USDC!.publicKey, 200, true, ); // Set price so health is below maintanence await envClient.stubOracleSet(group, btcOracle.publicKey, 1); await mangoAccountB!.reload(clientB); await clientA.liqTokenWithToken( group, mangoAccountA!, mangoAccountB!, mintsMap.BTC!.publicKey, mintsMap.USDC!.publicKey, 1000, ); }); it('update index and rate', async () => { let bank = (await envClient.getBanksForGroup(group))[0]; const lastUpdated = bank.indexLastUpdated; await envClient.updateIndexAndRate(group, mintsMap.USDC!.publicKey); bank = (await envClient.getBanksForGroup(group))[0]; assert.isTrue( bank.indexLastUpdated > lastUpdated, 'Index timestamp updated', ); }); it('calculates entry and break even price correctly', async () => { const { client: clientA, mangoAccount: accountA } = users[2]; const { client: clientB, mangoAccount: accountB } = users[3]; await accountA!.reload(clientA); await accountB!.reload(clientB); const btcPerp = (await envClient.perpGetMarkets(group))[0]; const positionA = accountA.getPerpPosition(btcPerp.perpMarketIndex)!; const positionB = accountB.getPerpPosition(btcPerp.perpMarketIndex)!; assert.equal(positionA.getBasePositionUi(btcPerp), 1, 'Position is long'); assert.equal(positionB.getBasePositionUi(btcPerp), -1, 'Position is short'); assert.isTrue( positionA.getEntryPrice(btcPerp).eq(new BN(99.0)), 'long entry price matches', ); assert.isTrue( positionB.getEntryPrice(btcPerp).eq(new BN(99.0)), 'short entry price matches', ); assert.isTrue( positionA.getBreakEvenPrice(btcPerp).eq(new BN(99.0)), 'long break even price matches', ); assert.isTrue( positionB.getBreakEvenPrice(btcPerp).eq(new BN(99.0)), 'short break even price matches', ); }); });