diff --git a/lib/client/rpc/market-clients/ammMarkets.ts b/lib/client/rpc/market-clients/ammMarkets.ts index 69dc17d..ec9db11 100644 --- a/lib/client/rpc/market-clients/ammMarkets.ts +++ b/lib/client/rpc/market-clients/ammMarkets.ts @@ -224,10 +224,17 @@ export class FutarchyAmmMarketsRPCClient implements FutarchyAmmMarketsClient { this.rpcProvider.publicKey ); const tx = await ix.transaction(); - return this.transactionSender.send([tx], this.rpcProvider.connection, { - customErrors: [this.ammClient.program.idl.errors], - CUs: 100_000 - }); + return this.transactionSender.send( + [tx], + this.rpcProvider.connection, + { + customErrors: [this.ammClient.program.idl.errors], + CUs: 100_000 + }, + { + title: "Adding Liquidity" + } + ); } simulateAddLiquidity( @@ -377,10 +384,15 @@ export class FutarchyAmmMarketsRPCClient implements FutarchyAmmMarketsClient { ) .transaction(); - return this.transactionSender?.send([tx], this.rpcProvider.connection, { - customErrors: [this.ammClient.program.idl.errors], - CUs: 80_000 - }); + return this.transactionSender?.send( + [tx], + this.rpcProvider.connection, + { + customErrors: [this.ammClient.program.idl.errors], + CUs: 80_000 + }, + { title: "Swapping" } + ); } async getSwapPreview( diff --git a/lib/client/rpc/market-clients/openbookMarkets.ts b/lib/client/rpc/market-clients/openbookMarkets.ts index 3911152..9b38907 100644 --- a/lib/client/rpc/market-clients/openbookMarkets.ts +++ b/lib/client/rpc/market-clients/openbookMarkets.ts @@ -96,15 +96,15 @@ export class FutarchyOpenbookMarketsRPCClient const baseTokenWithSymbol = !baseToken.isFallback ? baseToken : { - ...baseToken, - symbol: marketName.split("/")[0] - }; + ...baseToken, + symbol: marketName.split("/")[0] + }; const quoteTokenWithSymbol = !quoteToken.isFallback ? quoteToken : { - ...quoteToken, - symbol: marketName.split("/")[0] - }; + ...quoteToken, + symbol: marketName.split("/")[0] + }; return { baseMint: obMarket.account.baseMint, @@ -295,7 +295,9 @@ export class FutarchyOpenbookMarketsRPCClient .preInstructions(openTx.instructions) .transaction(); - return this.transactionSender.send([placeTx], this.rpcProvider.connection, { customErrors: [market.twapProgram.idl.errors] }); + return this.transactionSender.send([placeTx], this.rpcProvider.connection, { + customErrors: [market.twapProgram.idl.errors] + }); } async getOrCreateOpenOrdersIndexer({ @@ -403,21 +405,25 @@ export class FutarchyOpenbookMarketsRPCClient ] }; const tx = await this.cancelAndSettleFundsTransactions(order, market); - return this.transactionSender.send([tx], this.rpcProvider.connection, { customErrors: [this.openbookClient.program.idl.errors] }); + if (tx) { + return this.transactionSender.send([tx], this.rpcProvider.connection, { + customErrors: [this.openbookClient.program.idl.errors] + }); + } } private async cancelAndSettleFundsTransactions( order: OpenbookOrder, market: OpenbookMarket ) { - if (!this.transactionSender) return []; + if (!this.transactionSender) return null; const openOrders = await OpenOrders.load( order.owner, market.marketInstance, this.openbookClient ); if (!openOrders) { - return []; + return null; } const userBaseAccount = getAssociatedTokenAddressSync( diff --git a/lib/client/rpc/proposals/createProposal.ts b/lib/client/rpc/proposals/createProposal.ts index bac8fb4..7402236 100644 --- a/lib/client/rpc/proposals/createProposal.ts +++ b/lib/client/rpc/proposals/createProposal.ts @@ -28,7 +28,7 @@ import { AnchorProvider, Program, BN } from "@coral-xyz/anchor"; import { AutocratClient, InstructionUtils, - MaxCUs, + MaxCUs } from "@metadaoproject/futarchy"; import { OpenBookV2Client } from "@openbook-dex/openbook-v2"; import { @@ -95,8 +95,10 @@ export class CreateProposalClient implements CreateProposal { true ); - const vaultExists = await this.rpcProvider.connection.getAccountInfo(vaultUnderlyingTokenAccount) - if (vaultExists) return [vault, undefined] + const vaultExists = await this.rpcProvider.connection.getAccountInfo( + vaultUnderlyingTokenAccount + ); + if (vaultExists) return [vault, undefined]; const ix = ( await vaultProgram.methods @@ -205,10 +207,18 @@ export class CreateProposalClient implements CreateProposal { twapProgram.programId ); - const existingPassBaseMint = (await vaultProgram.account.conditionalVault.fetch(baseVault))?.conditionalOnFinalizeTokenMint - const existingFailBaseMint = (await vaultProgram.account.conditionalVault.fetch(baseVault))?.conditionalOnRevertTokenMint - const existingPassQuoteMint = (await vaultProgram.account.conditionalVault.fetch(quoteVault))?.conditionalOnFinalizeTokenMint - const existingFailQuoteMint = (await vaultProgram.account.conditionalVault.fetch(quoteVault))?.conditionalOnRevertTokenMint + const existingPassBaseMint = ( + await vaultProgram.account.conditionalVault.fetch(baseVault) + )?.conditionalOnFinalizeTokenMint; + const existingFailBaseMint = ( + await vaultProgram.account.conditionalVault.fetch(baseVault) + )?.conditionalOnRevertTokenMint; + const existingPassQuoteMint = ( + await vaultProgram.account.conditionalVault.fetch(quoteVault) + )?.conditionalOnFinalizeTokenMint; + const existingFailQuoteMint = ( + await vaultProgram.account.conditionalVault.fetch(quoteVault) + )?.conditionalOnRevertTokenMint; let [passMarketIx, passMarketSigners] = await openbook.createMarketIx( this.rpcProvider.publicKey, @@ -314,8 +324,6 @@ export class CreateProposalClient implements CreateProposal { .transaction() ).instructions; - - // initializeProposal needs to be a versioned tx to be able to partial sign with wallet signAllTransactions // signing after a partial sign overwrites it // versionedTransaction have no partialSign fn because it's sign fn is a partialSign @@ -332,11 +340,12 @@ export class CreateProposalClient implements CreateProposal { createFailMarketTx, createTwaps, initializeProposalTx - ].filter(tx => tx !== undefined) + ].filter((tx) => tx !== undefined); const txResp = await this.transactionSender?.send( allTxs, this.rpcProvider.connection, - { commitment: "confirmed", sequential: true } + { commitment: "confirmed", sequential: true }, + { title: "Creating Proposal" } ); const accounts = { @@ -360,21 +369,23 @@ export class CreateProposalClient implements CreateProposal { onPassIx: ProposalInstructionWithPreinstructions, marketParams: AmmMarketParams ): SendTransactionResponse { - if (!this.transactionSender) return + if (!this.transactionSender) return; const nonce = new BN(Math.random() * 2 ** 50); const proposal = PublicKey.findProgramAddressSync( [ Buffer.from("proposal"), this.rpcProvider.publicKey.toBuffer(), - nonce.toArrayLike(Buffer, "le", 8), + nonce.toArrayLike(Buffer, "le", 8) ], this.autocratClient.autocrat.programId - )[0] + )[0]; - const accountInfo = await this.rpcProvider.connection.getAccountInfo(proposal); + const accountInfo = await this.rpcProvider.connection.getAccountInfo( + proposal + ); if (accountInfo !== null) { - this.createProposalV0_3(dao, onPassIx, marketParams) + this.createProposalV0_3(dao, onPassIx, marketParams); } const autocrat = this.autocratClient.autocrat; @@ -383,28 +394,48 @@ export class CreateProposalClient implements CreateProposal { const baseTokensToLP = new BN(marketParams.baseLiquidity); const quoteTokensToLP = new BN(marketParams.quoteLiquidity); + const { + failAmm, + passAmm, + failBaseMint, + failQuoteMint, + failLp, + passBaseMint, + passQuoteMint, + passLp, + baseVault, + quoteVault + } = this.autocratClient.getProposalPdas( + proposal, + daoAccount.tokenMint, + daoAccount.usdcMint, + dao.publicKey + ); - const { failAmm, passAmm, failBaseMint, failQuoteMint, failLp, passBaseMint, passQuoteMint, passLp, baseVault, quoteVault } = this.autocratClient.getProposalPdas(proposal, daoAccount.tokenMint, daoAccount.usdcMint, dao.publicKey) - - const initializeVaultsAndCreateAmmsTx = await this.autocratClient.vaultClient - .initializeVaultIx(proposal, daoAccount.tokenMint) - .postInstructions( - await InstructionUtils.getInstructions( - this.autocratClient.vaultClient.initializeVaultIx(proposal, daoAccount.usdcMint), - this.autocratClient.ammClient.createAmmIx( - passBaseMint, - passQuoteMint, - daoAccount.twapInitialObservation, - daoAccount.twapMaxObservationChangePerUpdate - ), - this.autocratClient.ammClient.createAmmIx( - failBaseMint, - failQuoteMint, - daoAccount.twapInitialObservation, - daoAccount.twapMaxObservationChangePerUpdate + const initializeVaultsAndCreateAmmsTx = + await this.autocratClient.vaultClient + .initializeVaultIx(proposal, daoAccount.tokenMint) + .postInstructions( + await InstructionUtils.getInstructions( + this.autocratClient.vaultClient.initializeVaultIx( + proposal, + daoAccount.usdcMint + ), + this.autocratClient.ammClient.createAmmIx( + passBaseMint, + passQuoteMint, + daoAccount.twapInitialObservation, + daoAccount.twapMaxObservationChangePerUpdate + ), + this.autocratClient.ammClient.createAmmIx( + failBaseMint, + failQuoteMint, + daoAccount.twapInitialObservation, + daoAccount.twapMaxObservationChangePerUpdate + ) ) ) - ).transaction() + .transaction(); const mintTx = await this.autocratClient.vaultClient .mintConditionalTokensIx(baseVault, daoAccount.tokenMint, baseTokensToLP) @@ -414,22 +445,23 @@ export class CreateProposalClient implements CreateProposal { quoteVault, daoAccount.usdcMint, quoteTokensToLP - ), + ) ) ) - .transaction() + .transaction(); - const liquidityTx = await this.autocratClient.ammClient.addLiquidityIx( - failAmm, - failBaseMint, - failQuoteMint, - quoteTokensToLP, - baseTokensToLP, - new BN(0) - ).postInstructions( - await InstructionUtils.getInstructions( - this.autocratClient.ammClient - .addLiquidityIx( + const liquidityTx = await this.autocratClient.ammClient + .addLiquidityIx( + failAmm, + failBaseMint, + failQuoteMint, + quoteTokensToLP, + baseTokensToLP, + new BN(0) + ) + .postInstructions( + await InstructionUtils.getInstructions( + this.autocratClient.ammClient.addLiquidityIx( passAmm, passBaseMint, passQuoteMint, @@ -437,8 +469,9 @@ export class CreateProposalClient implements CreateProposal { baseTokensToLP, new BN(0) ) + ) ) - ).transaction() + .transaction(); const initializeProposalTx = await this.autocratClient .initializeProposalIx( @@ -451,18 +484,26 @@ export class CreateProposalClient implements CreateProposal { quoteTokensToLP, nonce ) - .preInstructions([ - ...(onPassIx.preInstructions || []), - ]) - .transaction() + .preInstructions([...(onPassIx.preInstructions || [])]) + .transaction(); - const allTxs = [initializeVaultsAndCreateAmmsTx, mintTx, liquidityTx, initializeProposalTx]; + const allTxs = [ + initializeVaultsAndCreateAmmsTx, + mintTx, + liquidityTx, + initializeProposalTx + ]; //TO DO : recalculate compute units - const initializeVaultsAndAmmCus = MaxCUs.createIdempotent * 2 + MaxCUs.initializeConditionalVault * 2 + MaxCUs.initializeAmm * 2 - const mintCus = MaxCUs.createIdempotent * 4 + MaxCUs.mintConditionalTokens * 2 + 50000; - const addLiquidityCus = + MaxCUs.addLiquidity * 2; - const initializeProposalCus = MaxCUs.createIdempotent + MaxCUs.initializeProposal + 50000; + const initializeVaultsAndAmmCus = + MaxCUs.createIdempotent * 2 + + MaxCUs.initializeConditionalVault * 2 + + MaxCUs.initializeAmm * 2; + const mintCus = + MaxCUs.createIdempotent * 4 + MaxCUs.mintConditionalTokens * 2 + 50000; + const addLiquidityCus = +MaxCUs.addLiquidity * 2; + const initializeProposalCus = + MaxCUs.createIdempotent + MaxCUs.initializeProposal + 50000; const txResp = await this.transactionSender?.send( allTxs, @@ -476,7 +517,8 @@ export class CreateProposalClient implements CreateProposal { addLiquidityCus, initializeProposalCus ] - } + }, + { title: "Creating Proposal" } ); const accounts = { diff --git a/lib/client/rpc/proposals/finalizeProposal.ts b/lib/client/rpc/proposals/finalizeProposal.ts index be4d8ea..c624c59 100644 --- a/lib/client/rpc/proposals/finalizeProposal.ts +++ b/lib/client/rpc/proposals/finalizeProposal.ts @@ -118,7 +118,8 @@ export class FinalizeProposalClient implements FinalizeProposal { this.rpcProvider.connection, { customErrors: [autocrat.idl.errors] - } + }, + { title: "Finalizing Proposal" } ); } @@ -129,22 +130,24 @@ export class FinalizeProposalClient implements FinalizeProposal { proposal.publicKey ); const dao = await this.autocratClient.getDao(proposalAccount.dao); - const finalizeProposalTx = - await this.autocratClient.finalizeProposalIx( + const finalizeProposalTx = await this.autocratClient + .finalizeProposalIx( proposal.publicKey, proposalAccount.instruction, proposalAccount.dao, dao.tokenMint, dao.usdcMint, proposalAccount.proposer - ).transaction() + ) + .transaction(); return await this.transactionSender?.send( [finalizeProposalTx], this.rpcProvider.connection, { customErrors: [this.autocratClient.autocrat.idl.errors] - } + }, + { title: "Finalizing Proposal" } ); } catch (e) { console.log("error", e); diff --git a/lib/client/rpc/proposals/proposals.ts b/lib/client/rpc/proposals/proposals.ts index a0a80b1..77ab716 100644 --- a/lib/client/rpc/proposals/proposals.ts +++ b/lib/client/rpc/proposals/proposals.ts @@ -204,10 +204,15 @@ export class FutarchyRPCProposalsClient implements FutarchyProposalsClient { mintConditionalsIx ]; const tx = new Transaction().add(...ixs); - return this.transactionSender.send([tx], this.rpcProvider.connection, { - customErrors: [vaultAccount.protocol.vault.idl.errors], - CUs: 80_000 - }); + return this.transactionSender.send( + [tx], + this.rpcProvider.connection, + { + customErrors: [vaultAccount.protocol.vault.idl.errors], + CUs: 80_000 + }, + { title: "Minting" } + ); } public async createProposal( @@ -309,7 +314,8 @@ export class FutarchyRPCProposalsClient implements FutarchyProposalsClient { { customErrors: [vaultProgram.idl.errors], CUs: 80_000 - } + }, + { title: "Merging Conditional Tokens" } ); return resp; } else throw Error("Version not compatible"); @@ -362,7 +368,8 @@ export class FutarchyRPCProposalsClient implements FutarchyProposalsClient { { customErrors: [vaultProgram.idl.errors], CUs: 130_000 - } + }, + { title: "Withdrawing" } ); return resp; } diff --git a/lib/transactions.ts b/lib/transactions.ts index 827904e..193e403 100644 --- a/lib/transactions.ts +++ b/lib/transactions.ts @@ -2,7 +2,9 @@ import { Commitment, ComputeBudgetProgram, Connection, + Context, PublicKey, + SignatureResult, Transaction, TransactionInstruction, TransactionMessage, @@ -10,12 +12,17 @@ import { } from "@solana/web3.js"; import { SendTransactionResponse, - TransactionError + TransactionError, + TransactionDisplayMetadata, + TransactionProcessingUpdate, + SendTransactionOptions } from "./types/transactions"; import { AnchorProvider } from "@coral-xyz/anchor"; type SingleOrArray = T | T[]; +const COMMITMENTS_TO_WATCH: Commitment[] = ["confirmed", "finalized"]; + export class TransactionSender { public owner: PublicKey; private signAllTransactions: ( @@ -23,17 +30,20 @@ export class TransactionSender { ) => Promise; public priorityFee: number; + public onTransactionUpdate?: (update: TransactionProcessingUpdate) => void; constructor( owner: PublicKey, signAllTransactions: ( transactions: T[] ) => Promise, - priorityFee: number + priorityFee: number, + onTransactionUpdate?: (update: TransactionProcessingUpdate) => void ) { this.owner = owner; this.signAllTransactions = signAllTransactions; this.priorityFee = priorityFee; + this.onTransactionUpdate = onTransactionUpdate; } static make( @@ -41,24 +51,193 @@ export class TransactionSender { signAllTransactions: ( transactions: T[] ) => Promise, - priorityFee: number + priorityFee: number, + onTransactionUpdate?: (update: TransactionProcessingUpdate) => void ): TransactionSender { - return new TransactionSender(owner, signAllTransactions, priorityFee); + return new TransactionSender( + owner, + signAllTransactions, + priorityFee, + onTransactionUpdate + ); + } + + private async handleTransactionUpdate( + update: TransactionProcessingUpdate, + connection: Connection, + signatureSubscriptionIds: number[] + ) { + if (update.status === "failed") { + //TODO stop subscribing + await Promise.all( + signatureSubscriptionIds.map(async (s) => + connection.removeSignatureListener(s) + ) + ); + } + this.onTransactionUpdate?.(update); + } + + async send( + tx: T[], + connection: Connection, + opts?: SendTransactionOptions, + displayMetadata?: TransactionDisplayMetadata + ): SendTransactionResponse { + let signatureSubscriptionIds: number[] = []; + try { + const sendRes = await this.sendInner(tx, connection, opts); + if ((sendRes?.errors?.length ?? 0) > 0) { + if ( + sendRes?.errors?.some((e) => e.name === "WalletSignTransactionError") + ) { + this.handleTransactionUpdate( + { + signature: sendRes?.signatures[0] ?? "", + errors: sendRes?.errors ?? [], + status: "unsigned", + displayMetadata + }, + connection, + signatureSubscriptionIds + ); + return; + } + this.handleTransactionUpdate( + { + signature: sendRes?.signatures[0] ?? "", + errors: sendRes?.errors ?? [], + status: "failed", + displayMetadata + }, + connection, + signatureSubscriptionIds + ); + return; + } + if (!sendRes?.signatures[0]) { + this.handleTransactionUpdate( + { + signature: "", + errors: [ + { message: "no signature returned", name: "Signature Missing" } + ], + status: "failed", + displayMetadata + }, + connection, + signatureSubscriptionIds + ); + return; + } + + const txSignature = sendRes.signatures[0]; + this.handleTransactionUpdate( + { + status: "sent", + errors: [], + signature: txSignature, + displayMetadata + }, + connection, + signatureSubscriptionIds + ); + signatureSubscriptionIds = COMMITMENTS_TO_WATCH.map((s) => { + return connection.onSignature( + txSignature, + (signatureResult: SignatureResult, _context: Context) => + this.handleSignatureResult( + txSignature, + signatureResult, + connection, + signatureSubscriptionIds, + displayMetadata + ), + s + ); + }); + + return sendRes; + } catch (e) { + const error = { + message: JSON.stringify(e), + name: "general error" + }; + this.handleTransactionUpdate( + { + signature: "", + errors: [error], + status: "failed", + displayMetadata + }, + connection, + signatureSubscriptionIds + ); + return { + signatures: [], + errors: [error] + }; + } } + + private async handleSignatureResult( + txSignature: string, + signatureResult: SignatureResult, + connection: Connection, + signatureSubscriptionIds: number[], + displayMetadata?: TransactionDisplayMetadata + ) { + if (signatureResult.err) { + const errors = + typeof signatureResult.err === "string" + ? [{ message: signatureResult.err, name: signatureResult.err }] + : [ + { + message: JSON.stringify(signatureResult.err), + name: JSON.stringify(signatureResult.err) + } + ]; + this.handleTransactionUpdate( + { + signature: txSignature, + errors: errors, + status: "failed", + displayMetadata + }, + connection, + signatureSubscriptionIds + ); + return; + } + + const statusRes = await connection.getSignatureStatus(txSignature); + const status = statusRes.value; + if (status?.confirmationStatus) { + this.handleTransactionUpdate( + { + signature: txSignature, + errors: [], + status: status.confirmationStatus, + displayMetadata + }, + connection, + signatureSubscriptionIds + ); + } else { + // error + console.warn("tx signature update is missing status"); + } + } + /** * Sends transactions. * @param txs A sequence of sets of transactions. Sets are executed simultaneously. * @returns A sequence of set of tx signatures. */ - async send( + async sendInner( txs: SingleOrArray[], connection: Connection, - opts?: { - sequential?: boolean; - commitment?: Commitment; - CUs?: SingleOrArray; - customErrors?: { code: number; name: string; msg: string }[][]; - } + opts?: SendTransactionOptions ): SendTransactionResponse { if (!connection || !this.owner || !this.signAllTransactions) { throw new Error("Bad wallet connection"); diff --git a/lib/types/transactions.ts b/lib/types/transactions.ts index 5707955..4132eb2 100644 --- a/lib/types/transactions.ts +++ b/lib/types/transactions.ts @@ -1,3 +1,31 @@ -export type SendTransactionResponse = Promise -export type SendTransactionResponseSync = { signatures: string[], errors?: TransactionError[] } | undefined -export type TransactionError = { message: string, name: string } \ No newline at end of file +import { Commitment, TransactionConfirmationStatus } from "@solana/web3.js"; + +export type SendTransactionResponse = Promise; +export type SendTransactionResponseSync = + | { signatures: string[]; errors?: TransactionError[] } + | undefined; +export type TransactionError = { message: string; name: string }; +export type TransactionProcessingStatus = + | TransactionConfirmationStatus + | ("sent" | "failed" | "timed-out" | "unsigned"); +type SingleOrArray = T | T[]; +export type TransactionDisplayMetadata = { + title: string; + description?: string; + image?: string; +}; + +export type TransactionProcessingUpdate = { + status: TransactionProcessingStatus; + errors: TransactionError[]; + signature: string; + clientIdentifier?: string; + displayMetadata?: TransactionDisplayMetadata; +}; + +export type SendTransactionOptions = { + sequential?: boolean; + commitment?: Commitment; + CUs?: SingleOrArray; + customErrors?: { code: number; name: string; msg: string }[][]; +};