From 694fc0af4763f27b47ce29c5bb2db937bea5cfc5 Mon Sep 17 00:00:00 2001 From: Britt Cyr Date: Fri, 11 Oct 2024 23:09:22 -0400 Subject: [PATCH] Add optional fields for globals in idl and sdk for swap (#176) * Add global as optional to swap * add test for swap global * fmt * update idl * optional init global in swap * fix test * fmt * fix expectations * flip test * update wallet expectations * fmt * balances * fmt * fix amount * Update program * lint --- client/idl/manifest.json | 25 +++- client/ts/src/client.ts | 17 ++- client/ts/src/manifest/instructions/Swap.ts | 85 ++++++++++-- client/ts/tests/createGlobal.ts | 1 + client/ts/tests/placeOrder.ts | 7 +- client/ts/tests/swap.ts | 126 ++++++++++++++++-- programs/manifest/src/program/instruction.rs | 10 +- programs/manifest/src/validation/loaders.rs | 105 ++++++++------- .../wrapper/src/processors/batch_upate.rs | 38 +++--- 9 files changed, 314 insertions(+), 100 deletions(-) diff --git a/client/idl/manifest.json b/client/idl/manifest.json index 6ae5ebdcd..6612ec5ac 100644 --- a/client/idl/manifest.json +++ b/client/idl/manifest.json @@ -323,14 +323,16 @@ "name": "baseMint", "isMut": false, "isSigner": false, + "isOptional": true, "docs": [ - "Base mint, only inlcuded if base is Token22, otherwise not required" + "Base mint, only included if base is Token22, otherwise not required" ] }, { "name": "tokenProgramQuote", "isMut": false, "isSigner": false, + "isOptional": true, "docs": [ "Token program(22) quote. Optional. Only include if different from base" ] @@ -339,8 +341,27 @@ "name": "quoteMint", "isMut": false, "isSigner": false, + "isOptional": true, "docs": [ - "Quote mint, only inlcuded if base is Token22, otherwise not required" + "Quote mint, only included if base is Token22, otherwise not required" + ] + }, + { + "name": "global", + "isMut": true, + "isSigner": false, + "isOptional": true, + "docs": [ + "Global account" + ] + }, + { + "name": "globalVault", + "isMut": true, + "isSigner": false, + "isOptional": true, + "docs": [ + "Global vault" ] } ], diff --git a/client/ts/src/client.ts b/client/ts/src/client.ts index 711f1b2b4..fca9f8e34 100644 --- a/client/ts/src/client.ts +++ b/client/ts/src/client.ts @@ -712,8 +712,21 @@ export class ManifestClient { this.market.address, this.quoteMint.address, ); + + const global: PublicKey = getGlobalAddress( + params.isBaseIn ? this.quoteMint.address : this.baseMint.address, + ); + const globalVault: PublicKey = getGlobalVaultAddress( + params.isBaseIn ? this.quoteMint.address : this.baseMint.address, + ); + // Assumes just normal token program for now. - // No Token22 support here in sdk yet. + // No Token22 support here in sdk yet, but includes programs and mints as + // though it was. + + // No support for the case where global are not needed. That is an + // optimization that needs to be made when looking at the orderbook and + // deciding if it is worthwhile to lock the accounts. return createSwapInstruction( { payer, @@ -730,6 +743,8 @@ export class ManifestClient { ? TOKEN_2022_PROGRAM_ID : TOKEN_PROGRAM_ID, quoteMint: this.quoteMint.address, + global, + globalVault, }, { params, diff --git a/client/ts/src/manifest/instructions/Swap.ts b/client/ts/src/manifest/instructions/Swap.ts index 810e739cb..2d6692444 100644 --- a/client/ts/src/manifest/instructions/Swap.ts +++ b/client/ts/src/manifest/instructions/Swap.ts @@ -43,9 +43,11 @@ export const SwapStruct = new beet.BeetArgsStruct< * @property [_writable_] baseVault * @property [_writable_] quoteVault * @property [] tokenProgramBase - * @property [] baseMint - * @property [] tokenProgramQuote - * @property [] quoteMint + * @property [] baseMint (optional) + * @property [] tokenProgramQuote (optional) + * @property [] quoteMint (optional) + * @property [_writable_] global (optional) + * @property [_writable_] globalVault (optional) * @category Instructions * @category Swap * @category generated @@ -58,9 +60,11 @@ export type SwapInstructionAccounts = { baseVault: web3.PublicKey; quoteVault: web3.PublicKey; tokenProgramBase: web3.PublicKey; - baseMint: web3.PublicKey; - tokenProgramQuote: web3.PublicKey; - quoteMint: web3.PublicKey; + baseMint?: web3.PublicKey; + tokenProgramQuote?: web3.PublicKey; + quoteMint?: web3.PublicKey; + global?: web3.PublicKey; + globalVault?: web3.PublicKey; }; export const swapInstructionDiscriminator = 4; @@ -68,6 +72,11 @@ export const swapInstructionDiscriminator = 4; /** * Creates a _Swap_ instruction. * + * Optional accounts that are not provided will be omitted from the accounts + * array passed with the instruction. + * An optional account that is set cannot follow an optional account that is unset. + * Otherwise an Error is raised. + * * @param accounts that will be accessed while the instruction is processed * @param args to provide as instruction data to the program * @@ -120,22 +129,72 @@ export function createSwapInstruction( isWritable: false, isSigner: false, }, - { + ]; + + if (accounts.baseMint != null) { + keys.push({ pubkey: accounts.baseMint, isWritable: false, isSigner: false, - }, - { + }); + } + if (accounts.tokenProgramQuote != null) { + if (accounts.baseMint == null) { + throw new Error( + "When providing 'tokenProgramQuote' then 'accounts.baseMint' need(s) to be provided as well.", + ); + } + keys.push({ pubkey: accounts.tokenProgramQuote, isWritable: false, isSigner: false, - }, - { + }); + } + if (accounts.quoteMint != null) { + if (accounts.baseMint == null || accounts.tokenProgramQuote == null) { + throw new Error( + "When providing 'quoteMint' then 'accounts.baseMint', 'accounts.tokenProgramQuote' need(s) to be provided as well.", + ); + } + keys.push({ pubkey: accounts.quoteMint, isWritable: false, isSigner: false, - }, - ]; + }); + } + if (accounts.global != null) { + if ( + accounts.baseMint == null || + accounts.tokenProgramQuote == null || + accounts.quoteMint == null + ) { + throw new Error( + "When providing 'global' then 'accounts.baseMint', 'accounts.tokenProgramQuote', 'accounts.quoteMint' need(s) to be provided as well.", + ); + } + keys.push({ + pubkey: accounts.global, + isWritable: true, + isSigner: false, + }); + } + if (accounts.globalVault != null) { + if ( + accounts.baseMint == null || + accounts.tokenProgramQuote == null || + accounts.quoteMint == null || + accounts.global == null + ) { + throw new Error( + "When providing 'globalVault' then 'accounts.baseMint', 'accounts.tokenProgramQuote', 'accounts.quoteMint', 'accounts.global' need(s) to be provided as well.", + ); + } + keys.push({ + pubkey: accounts.globalVault, + isWritable: true, + isSigner: false, + }); + } const ix = new web3.TransactionInstruction({ programId, diff --git a/client/ts/tests/createGlobal.ts b/client/ts/tests/createGlobal.ts index 33e4fdf3c..d6d364d6d 100644 --- a/client/ts/tests/createGlobal.ts +++ b/client/ts/tests/createGlobal.ts @@ -46,6 +46,7 @@ export async function createGlobal( tokenMint: PublicKey, ): Promise { console.log(`Cluster is ${await getClusterFromConnection(connection)}`); + await airdropSol(connection, payerKeypair.publicKey); const createGlobalIx = await ManifestClient['createGlobalCreateIx']( connection, diff --git a/client/ts/tests/placeOrder.ts b/client/ts/tests/placeOrder.ts index efdbfd64a..f30b142d5 100644 --- a/client/ts/tests/placeOrder.ts +++ b/client/ts/tests/placeOrder.ts @@ -11,6 +11,7 @@ import { createMarket } from './createMarket'; import { deposit } from './deposit'; import { Market } from '../src/market'; import { assert } from 'chai'; +import { NO_EXPIRATION_LAST_VALID_SLOT } from '../src/constants'; async function testPlaceOrder(): Promise { const connection: Connection = new Connection( @@ -72,7 +73,7 @@ export async function placeOrder( isBid: boolean, orderType: OrderType, clientOrderId: number, - lastValidSlot: number = 0, + lastValidSlot: number = NO_EXPIRATION_LAST_VALID_SLOT, ): Promise { const client: ManifestClient = await ManifestClient.getClientForMarket( connection, @@ -84,8 +85,8 @@ export async function placeOrder( numBaseTokens, tokenPrice, isBid, - lastValidSlot: lastValidSlot, - orderType: orderType, + lastValidSlot, + orderType, clientOrderId, }); diff --git a/client/ts/tests/swap.ts b/client/ts/tests/swap.ts index 11fa22ba8..8dc29b8ee 100644 --- a/client/ts/tests/swap.ts +++ b/client/ts/tests/swap.ts @@ -11,9 +11,16 @@ import { createMarket } from './createMarket'; import { Market } from '../src/market'; import { createAssociatedTokenAccountIdempotent, + getAssociatedTokenAddress, mintTo, } from '@solana/spl-token'; import { assert } from 'chai'; +import { placeOrder } from './placeOrder'; +import { airdropSol } from '../src/utils/solana'; +import { depositGlobal } from './globalDeposit'; +import { createGlobal } from './createGlobal'; +import { OrderType } from '../src'; +import { NO_EXPIRATION_LAST_VALID_SLOT } from '../src/constants'; async function testSwap(): Promise { const connection: Connection = new Connection( @@ -76,15 +83,12 @@ export async function swap( payerKeypair, ); - const swapIx: TransactionInstruction = await client.swapIx( - payerKeypair.publicKey, - { - inAtoms: amountAtoms, - outAtoms: minOutAtoms, - isBaseIn: isBid, - isExactIn: true, - }, - ); + const swapIx: TransactionInstruction = client.swapIx(payerKeypair.publicKey, { + inAtoms: amountAtoms, + outAtoms: minOutAtoms, + isBaseIn: isBid, + isExactIn: true, + }); const signature = await sendAndConfirmTransaction( connection, @@ -94,8 +98,112 @@ export async function swap( console.log(`Placed order in ${signature}`); } +async function _testSwapGlobal(): Promise { + const connection: Connection = new Connection( + 'http://127.0.0.1:8899', + 'confirmed', + ); + const payerKeypair: Keypair = Keypair.generate(); + + const marketAddress: PublicKey = await createMarket(connection, payerKeypair); + const market: Market = await Market.loadFromAddress({ + connection, + address: marketAddress, + }); + + const traderBaseTokenAccount: PublicKey = + await createAssociatedTokenAccountIdempotent( + connection, + payerKeypair, + market.baseMint(), + payerKeypair.publicKey, + ); + // Initialize trader quote so they can receive. + await createAssociatedTokenAccountIdempotent( + connection, + payerKeypair, + market.quoteMint(), + payerKeypair.publicKey, + ); + + const amountBaseAtoms: number = 1_000_000_000; + const mintSig = await mintTo( + connection, + payerKeypair, + market.baseMint(), + traderBaseTokenAccount, + payerKeypair.publicKey, + amountBaseAtoms, + ); + console.log( + `Minted ${amountBaseAtoms} to ${traderBaseTokenAccount} in ${mintSig}`, + ); + + // Note that this is a self-trade for simplicity. + await airdropSol(connection, payerKeypair.publicKey); + await createGlobal(connection, payerKeypair, market.quoteMint()); + await depositGlobal(connection, payerKeypair, market.quoteMint(), 10_000); + await placeOrder( + connection, + payerKeypair, + marketAddress, + 5, + 5, + true, + OrderType.Global, + 1, + NO_EXPIRATION_LAST_VALID_SLOT, + ); + + await swap(connection, payerKeypair, marketAddress, amountBaseAtoms, false); + await market.reload(connection); + market.prettyPrint(); + + // Verify that the resting order got matched and resulted in deposited base on + // the market. Quote came from global and got withdrawn in the swap. Because + // it is a self-trade, it resets to zero, so we need to check the wallet. + assert( + market.getWithdrawableBalanceTokens(payerKeypair.publicKey, false) == 0, + `Expected quote ${0} actual quote ${market.getWithdrawableBalanceTokens(payerKeypair.publicKey, false)}`, + ); + assert( + market.getWithdrawableBalanceTokens(payerKeypair.publicKey, true) == 0, + `Expected base ${0} actual base ${market.getWithdrawableBalanceTokens(payerKeypair.publicKey, true)}`, + ); + const baseBalance: number = ( + await connection.getTokenAccountBalance( + await getAssociatedTokenAddress( + market.baseMint(), + payerKeypair.publicKey, + ), + ) + ).value.uiAmount!; + const quoteBalance: number = ( + await connection.getTokenAccountBalance( + await getAssociatedTokenAddress( + market.quoteMint(), + payerKeypair.publicKey, + ), + ) + ).value.uiAmount!; + // Because of the self trade, it resets the wallet to pre-trade amount. + assert( + baseBalance == 1, + `Expected wallet base ${1} actual base ${baseBalance}`, + ); + // 5 * 5, received from matching the global order. + assert( + quoteBalance == 25, + `Expected quote ${25} actual quote ${quoteBalance}`, + ); +} + describe('Swap test', () => { it('Swap', async () => { await testSwap(); }); + it('Swap against global', async () => { + // TODO: Enable once able to place global order through batch update + // await testSwapGlobal(); + }); }); diff --git a/programs/manifest/src/program/instruction.rs b/programs/manifest/src/program/instruction.rs index 385484e0e..73296473a 100644 --- a/programs/manifest/src/program/instruction.rs +++ b/programs/manifest/src/program/instruction.rs @@ -51,11 +51,11 @@ pub enum ManifestInstruction { #[account(4, writable, name = "base_vault", desc = "Base vault PDA, seeds are [b'vault', market_address, base_mint]")] #[account(5, writable, name = "quote_vault", desc = "Quote vault PDA, seeds are [b'vault', market_address, quote_mint]")] #[account(6, name = "token_program_base", desc = "Token program(22) base")] - #[account(7, name = "base_mint", desc = "Base mint, only inlcuded if base is Token22, otherwise not required")] - #[account(8, name = "token_program_quote", desc = "Token program(22) quote. Optional. Only include if different from base")] - #[account(9, name = "quote_mint", desc = "Quote mint, only inlcuded if base is Token22, otherwise not required")] - // #[account(10, writable, optional, name = "global", desc = "Global account")] - // #[account(11, writable, optional, name = "global_vault", desc = "Global vault")] + #[account(7, optional, name = "base_mint", desc = "Base mint, only included if base is Token22, otherwise not required")] + #[account(8, optional, name = "token_program_quote", desc = "Token program(22) quote. Optional. Only include if different from base")] + #[account(9, optional, name = "quote_mint", desc = "Quote mint, only included if base is Token22, otherwise not required")] + #[account(10, writable, optional, name = "global", desc = "Global account")] + #[account(11, writable, optional, name = "global_vault", desc = "Global vault")] Swap = 4, /// Expand a market. diff --git a/programs/manifest/src/validation/loaders.rs b/programs/manifest/src/validation/loaders.rs index 54f991e3f..21d4509ad 100644 --- a/programs/manifest/src/validation/loaders.rs +++ b/programs/manifest/src/validation/loaders.rs @@ -342,57 +342,64 @@ impl<'a, 'info> SwapContext<'a, 'info> { } if current_account_info_or.is_ok() { - let global: ManifestAccountInfo<'a, 'info, GlobalFixed> = - ManifestAccountInfo::::new(current_account_info_or?)?; - let global_data: Ref<&mut [u8]> = global.data.borrow(); - let global_fixed: &GlobalFixed = get_helper::(&global_data, 0_u32); - let global_mint_key: &Pubkey = global_fixed.get_mint(); - let expected_global_vault_address: &Pubkey = global_fixed.get_vault(); - - let global_vault: TokenAccountInfo<'a, 'info> = - TokenAccountInfo::new_with_owner_and_key( - next_account_info(account_iter)?, - global_mint_key, - &expected_global_vault_address, - &expected_global_vault_address, - )?; - - let index: usize = if *global_mint_key == base_mint_key { - 0 - } else { - require!( - quote_mint_key == *global_mint_key, - ManifestError::MissingGlobal, - "Unexpected global accounts", - )?; - 1 - }; + let current_account_info: &AccountInfo<'info> = current_account_info_or?; - drop(global_data); - global_trade_accounts_opts[index] = Some(GlobalTradeAccounts { - mint_opt: if index == 0 { - base_mint.clone() - } else { - quote_mint.clone() - }, - global, - global_vault_opt: Some(global_vault), - market_vault_opt: if index == 0 { - Some(base_vault.clone()) - } else { - Some(quote_vault.clone()) - }, - token_program_opt: if index == 0 { - Some(token_program_base.clone()) + // It is possible that the global account does not exist. Do not + // throw an error. This will happen when users just blindly include + // global accounts that have not been initialized. + if !current_account_info.data_is_empty() { + let global: ManifestAccountInfo<'a, 'info, GlobalFixed> = + ManifestAccountInfo::::new(current_account_info)?; + let global_data: Ref<&mut [u8]> = global.data.borrow(); + let global_fixed: &GlobalFixed = get_helper::(&global_data, 0_u32); + let global_mint_key: &Pubkey = global_fixed.get_mint(); + let expected_global_vault_address: &Pubkey = global_fixed.get_vault(); + + let global_vault: TokenAccountInfo<'a, 'info> = + TokenAccountInfo::new_with_owner_and_key( + next_account_info(account_iter)?, + global_mint_key, + &expected_global_vault_address, + &expected_global_vault_address, + )?; + + let index: usize = if *global_mint_key == base_mint_key { + 0 } else { - Some(token_program_quote.clone()) - }, - gas_payer_opt: None, - gas_receiver_opt: Some(payer.clone()), - market: *market.info.key, - // System program not included because does not rest an order. - system_program: None, - }); + require!( + quote_mint_key == *global_mint_key, + ManifestError::MissingGlobal, + "Unexpected global accounts", + )?; + 1 + }; + + drop(global_data); + global_trade_accounts_opts[index] = Some(GlobalTradeAccounts { + mint_opt: if index == 0 { + base_mint.clone() + } else { + quote_mint.clone() + }, + global, + global_vault_opt: Some(global_vault), + market_vault_opt: if index == 0 { + Some(base_vault.clone()) + } else { + Some(quote_vault.clone()) + }, + token_program_opt: if index == 0 { + Some(token_program_base.clone()) + } else { + Some(token_program_quote.clone()) + }, + gas_payer_opt: None, + gas_receiver_opt: Some(payer.clone()), + market: *market.info.key, + // System program not included because does not rest an order. + system_program: None, + }); + } } Ok(Self { diff --git a/programs/wrapper/src/processors/batch_upate.rs b/programs/wrapper/src/processors/batch_upate.rs index 89b67603b..c8c2e3c40 100644 --- a/programs/wrapper/src/processors/batch_upate.rs +++ b/programs/wrapper/src/processors/batch_upate.rs @@ -159,26 +159,28 @@ fn prepare_orders( // if they do not have the funds on the exchange that the orders // require. let mut num_base_atoms: u64 = order.base_atoms; - if order.is_bid { - let price = QuoteAtomsPerBaseAtom::try_from_mantissa_and_exponent( - order.price_mantissa, - order.price_exponent, - ) - .unwrap(); - let desired: QuoteAtoms = BaseAtoms::new(order.base_atoms) - .checked_mul(price, true) + if order.order_type != OrderType::Global { + if order.is_bid { + let price = QuoteAtomsPerBaseAtom::try_from_mantissa_and_exponent( + order.price_mantissa, + order.price_exponent, + ) .unwrap(); - if desired > *remaining_quote_atoms { - num_base_atoms = 0; - } else { - *remaining_quote_atoms -= desired; - } - } else { - let desired: BaseAtoms = BaseAtoms::new(order.base_atoms); - if desired > *remaining_base_atoms { - num_base_atoms = 0; + let desired: QuoteAtoms = BaseAtoms::new(order.base_atoms) + .checked_mul(price, true) + .unwrap(); + if desired > *remaining_quote_atoms { + num_base_atoms = 0; + } else { + *remaining_quote_atoms -= desired; + } } else { - *remaining_base_atoms -= desired; + let desired: BaseAtoms = BaseAtoms::new(order.base_atoms); + if desired > *remaining_base_atoms { + num_base_atoms = 0; + } else { + *remaining_base_atoms -= desired; + } } } let core_place: PlaceOrderParams = PlaceOrderParams::new(