From 170947ffbd1de885ac95c4b809b0e889d938eaaf Mon Sep 17 00:00:00 2001 From: Yolley Date: Fri, 5 Apr 2024 14:53:40 +0200 Subject: [PATCH] STREAM-1451: solana reliable tx execution (#158) * STREAM-1451: solana reliable tx execution --- lerna.json | 2 +- packages/common/package.json | 2 +- packages/common/solana/types.ts | 10 +- packages/common/solana/utils.ts | 199 +++++++++++++++++-------- packages/distributor/package.json | 2 +- packages/distributor/solana/client.ts | 24 ++- packages/distributor/solana/utils.ts | 10 +- packages/stream/package.json | 2 +- packages/stream/solana/StreamClient.ts | 103 ++++++++----- packages/stream/solana/types.ts | 4 +- packages/stream/solana/utils.ts | 35 ++--- 11 files changed, 263 insertions(+), 130 deletions(-) diff --git a/lerna.json b/lerna.json index 020de029..1c746ef6 100644 --- a/lerna.json +++ b/lerna.json @@ -2,6 +2,6 @@ "packages": [ "packages/*" ], - "version": "6.0.3", + "version": "6.1.0", "$schema": "node_modules/lerna/schemas/lerna-schema.json" } diff --git a/packages/common/package.json b/packages/common/package.json index 13aedd13..fddaf2dd 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@streamflow/common", - "version": "6.0.3", + "version": "6.1.0", "description": "Common utilities and types used by streamflow packages.", "homepage": "https://github.com/streamflow-finance/js-sdk/", "main": "dist/index.js", diff --git a/packages/common/solana/types.ts b/packages/common/solana/types.ts index 93930e68..ea7d6bd4 100644 --- a/packages/common/solana/types.ts +++ b/packages/common/solana/types.ts @@ -1,4 +1,4 @@ -import { AccountInfo, PublicKey } from "@solana/web3.js"; +import { AccountInfo, BlockhashWithExpiryBlockHeight, Commitment, Context, PublicKey } from "@solana/web3.js"; export interface ITransactionSolanaExt { computePrice?: number; @@ -23,3 +23,11 @@ export interface AtaParams { owner: PublicKey; programId?: PublicKey; } + +export interface ConfirmationParams { + hash: BlockhashWithExpiryBlockHeight; + context: Context; + commitment?: Commitment; +} + +export class TransactionFailedError extends Error {} diff --git a/packages/common/solana/utils.ts b/packages/common/solana/utils.ts index d756abe4..6ef3c646 100644 --- a/packages/common/solana/utils.ts +++ b/packages/common/solana/utils.ts @@ -9,21 +9,24 @@ import { import { SignerWalletAdapter } from "@solana/wallet-adapter-base"; import { BlockhashWithExpiryBlockHeight, - BlockheightBasedTransactionConfirmationStrategy, Commitment, ComputeBudgetProgram, Connection, Keypair, PublicKey, - sendAndConfirmRawTransaction, Transaction, TransactionInstruction, - TransactionExpiredBlockheightExceededError, SignatureStatus, + TransactionMessage, + VersionedTransaction, + Context, + RpcResponseAndContext, + SimulatedTransactionResponse, + SendTransactionError, } from "@solana/web3.js"; import bs58 from "bs58"; -import { Account, AtaParams, ITransactionSolanaExt } from "./types"; +import { Account, AtaParams, ConfirmationParams, ITransactionSolanaExt, TransactionFailedError } from "./types"; import { sleep } from "../utils"; /** @@ -74,6 +77,15 @@ export function isSignerKeypair(walletOrKeypair: Keypair | SignerWalletAdapter): ); } +/** + * Utility function to check whether given transaction is Versioned + * @param tx {Transaction | VersionedTransaction} - Transaction to check + * @returns {boolean} - Returns true if transaction is Versioned. + */ +export function isTransactionVersioned(tx: Transaction | VersionedTransaction): tx is VersionedTransaction { + return "message" in tx; +} + /** * Creates a Transaction with given instructions and optionally signs it. * @param connection - Solana client connection @@ -90,31 +102,36 @@ export async function prepareTransaction( commitment?: Commitment, ...partialSigners: (Keypair | undefined)[] ): Promise<{ - tx: Transaction; + tx: VersionedTransaction; hash: BlockhashWithExpiryBlockHeight; + context: Context; }> { - const hash = await connection.getLatestBlockhash(commitment); - const tx = new Transaction({ - feePayer: payer, - blockhash: hash.blockhash, - lastValidBlockHeight: hash.lastValidBlockHeight, - }).add(...ixs); - - for (const signer of partialSigners) { - if (signer) { - tx.partialSign(signer); - } - } + const { value: hash, context } = await connection.getLatestBlockhashAndContext(commitment); + const messageV0 = new TransactionMessage({ + payerKey: payer!, + recentBlockhash: hash.blockhash, + instructions: ixs, + }).compileToV0Message(); + const tx = new VersionedTransaction(messageV0); + const signers: Keypair[] = partialSigners.filter((item): item is Keypair => !!item); + tx.sign(signers); - return { tx, hash }; + return { tx, context, hash }; } -export async function signTransaction(invoker: Keypair | SignerWalletAdapter, tx: Transaction): Promise { - let signedTx: Transaction; +export async function signTransaction( + invoker: Keypair | SignerWalletAdapter, + tx: T, +): Promise { + let signedTx: T; if (isSignerWallet(invoker)) { signedTx = await invoker.signTransaction(tx); } else { - tx.partialSign(invoker); + if (isTransactionVersioned(tx)) { + tx.sign([invoker]); + } else { + tx.partialSign(invoker); + } signedTx = tx; } return signedTx; @@ -125,72 +142,125 @@ export async function signTransaction(invoker: Keypair | SignerWalletAdapter, tx * @param connection - Solana client connection * @param invoker - Keypair used as signer * @param tx - Transaction instance - * @param hash - blockhash information, the same hash should be used in the Transaction + * @param {ConfirmationParams} confirmationParams - Confirmation Params that will be used for execution * @returns Transaction signature */ export async function signAndExecuteTransaction( connection: Connection, invoker: Keypair | SignerWalletAdapter, - tx: Transaction, - hash: BlockhashWithExpiryBlockHeight, + tx: Transaction | VersionedTransaction, + confirmationParams: ConfirmationParams, ): Promise { const signedTx = await signTransaction(invoker, tx); - return executeTransaction(connection, signedTx, hash); + return executeTransaction(connection, signedTx, confirmationParams); } /** * Sends and confirms Transaction - * Confirmation strategy is not 100% reliable here as in times of congestion there can be a case that tx is executed, - * but is not in `commitment` state and so it's not considered executed by the `sendAndConfirmRawTransaction` method, - * and it raises an expiry error even though transaction may be executed soon. - * - so we add additional 50 blocks for checks to account for such issues; - * - also, we check for SignatureStatus one last time as it could be that websocket was slow to respond. + * Uses custom confirmation logic that: + * - simulates tx before sending separately + * - sends transaction without preFlight checks but with some valuable flags https://twitter.com/jordaaash/status/1774892862049800524?s=46&t=bhZ10V0r7IX5Lk5kKzxfGw + * - rebroadcasts a tx every 500 ms + * - after broadcasting check whether tx has executed once + * - catch errors for every actionable item, throw only the ones that signal that tx has failed + * - otherwise there is a chance of marking a landed tx as failed if it was broadcasted at least once * @param connection - Solana client connection * @param tx - Transaction instance * @param hash - blockhash information, the same hash should be used in the Transaction + * @param context - context at which blockhash has been retrieve + * @param commitment - optional commitment that will be used for simulation and confirmation * @returns Transaction signature */ export async function executeTransaction( connection: Connection, - tx: Transaction, - hash: BlockhashWithExpiryBlockHeight, + tx: Transaction | VersionedTransaction, + { hash, context, commitment }: ConfirmationParams, ): Promise { - const rawTx = tx.serialize(); + if (!hash.lastValidBlockHeight || tx.signatures.length === 0 || !hash.blockhash) { + throw Error("Error with transaction parameters."); + } + + for (let i = 0; i < 3; i++) { + let res: RpcResponseAndContext; + if (isTransactionVersioned(tx)) { + res = await connection.simulateTransaction(tx); + } else { + res = await connection.simulateTransaction(tx); + } + if (res.value.err) { + const errMessage = JSON.stringify(res.value.err); + if (!errMessage.includes("BlockhashNotFound") || i === 2) { + throw new SendTransactionError("failed to simulate transaction: " + errMessage, res.value.logs || undefined); + } + } + break; + } - if (!hash.lastValidBlockHeight || !tx.signature || !hash.blockhash) throw Error("Error with transaction parameters."); + const isVersioned = isTransactionVersioned(tx); - const signature = bs58.encode(tx.signature); - const confirmationStrategy: BlockheightBasedTransactionConfirmationStrategy = { - lastValidBlockHeight: hash.lastValidBlockHeight + 50, - signature, - blockhash: hash.blockhash, - }; - try { - return await sendAndConfirmRawTransaction(connection, rawTx, confirmationStrategy); - } catch (e) { - // If BlockHeight expired, we will check tx status one last time to make sure - if (e instanceof TransactionExpiredBlockheightExceededError) { - await sleep(1000); + let signature: string; + if (isVersioned) { + signature = bs58.encode(tx.signatures[0]); + } else { + signature = bs58.encode(tx.signature!); + } + + let blockheight = await connection.getBlockHeight(commitment); + let transactionSent = false; + const rawTransaction = tx.serialize(); + while (blockheight < hash.lastValidBlockHeight) { + try { + await connection.sendRawTransaction(rawTransaction, { + maxRetries: 0, + minContextSlot: context.slot, + preflightCommitment: commitment, + skipPreflight: true, + }); + transactionSent = true; + } catch (e) { + if ( + transactionSent || + (e instanceof SendTransactionError && e.message.includes("Minimum context slot has not been reached")) + ) { + continue; + } + throw e; + } + await sleep(500); + try { const value = await confirmAndEnsureTransaction(connection, signature); - if (!value) { + if (value) { + return signature; + } + } catch (e) { + if (e instanceof TransactionFailedError) { throw e; } - return signature; + await sleep(500); + } + + try { + blockheight = await connection.getBlockHeight(commitment); + } catch (_e) { + await sleep(500); } - throw e; } + + throw new Error(`Transaction ${signature} expired.`); } /** * Confirms and validates transaction success once * @param connection - Solana client connection * @param signature - Transaction signature + * @param passError - return status even if tx failed * @returns Transaction Status */ export async function confirmAndEnsureTransaction( connection: Connection, signature: string, + passError?: boolean, ): Promise { const response = await connection.getSignatureStatus(signature); if (!response) { @@ -200,9 +270,9 @@ export async function confirmAndEnsureTransaction( if (!value) { return null; } - if (value.err) { + if (!passError && value.err) { // That's how solana-web3js does it, `err` here is an object that won't really be handled - throw new Error(`Raw transaction ${signature} failed (${JSON.stringify({ err: value.err })})`); + throw new TransactionFailedError(`Raw transaction ${signature} failed (${JSON.stringify({ err: value.err })})`); } switch (connection.commitment) { case "confirmed": @@ -278,15 +348,18 @@ export async function enrichAtaParams(connection: Connection, paramsBatch: AtaPa * @param connection - Solana client connection * @param payer - Transaction invoker, should be a signer * @param paramsBatch - Array of Params for an each ATA account: {mint, owner} + * @param commitment - optional commitment that will be used to fetch Blockhash * @returns Unsigned Transaction with create ATA instructions */ export async function generateCreateAtaBatchTx( connection: Connection, payer: PublicKey, paramsBatch: AtaParams[], + commitment?: Commitment, ): Promise<{ - tx: Transaction; + tx: VersionedTransaction; hash: BlockhashWithExpiryBlockHeight; + context: Context; }> { paramsBatch = await enrichAtaParams(connection, paramsBatch); const ixs: TransactionInstruction[] = await Promise.all( @@ -294,13 +367,14 @@ export async function generateCreateAtaBatchTx( return createAssociatedTokenAccountInstruction(payer, await ata(mint, owner), owner, mint, programId); }), ); - const hash = await connection.getLatestBlockhash(); - const tx = new Transaction({ - feePayer: payer, - blockhash: hash.blockhash, - lastValidBlockHeight: hash.lastValidBlockHeight, - }).add(...ixs); - return { tx, hash }; + const { value: hash, context } = await connection.getLatestBlockhashAndContext({ commitment }); + const messageV0 = new TransactionMessage({ + payerKey: payer, + recentBlockhash: hash.blockhash, + instructions: ixs, + }).compileToV0Message(); + const tx = new VersionedTransaction(messageV0); + return { tx, hash, context }; } /** @@ -308,19 +382,22 @@ export async function generateCreateAtaBatchTx( * @param connection - Solana client connection * @param invoker - Transaction invoker and payer * @param paramsBatch - Array of Params for an each ATA account: {mint, owner} + * @param commitment - optional commitment that will be used to fetch Blockhash * @returns Transaction signature */ export async function createAtaBatch( connection: Connection, invoker: Keypair | SignerWalletAdapter, paramsBatch: AtaParams[], + commitment?: Commitment, ): Promise { - const { tx, hash } = await generateCreateAtaBatchTx( + const { tx, hash, context } = await generateCreateAtaBatchTx( connection, invoker.publicKey!, await enrichAtaParams(connection, paramsBatch), + commitment, ); - return signAndExecuteTransaction(connection, invoker, tx, hash); + return signAndExecuteTransaction(connection, invoker, tx, { hash, context, commitment }); } /** diff --git a/packages/distributor/package.json b/packages/distributor/package.json index 7ba28370..0840bcf4 100644 --- a/packages/distributor/package.json +++ b/packages/distributor/package.json @@ -1,6 +1,6 @@ { "name": "@streamflow/distributor", - "version": "6.0.3", + "version": "6.1.0", "description": "JavaScript SDK to interact with Streamflow Airdrop protocol.", "homepage": "https://github.com/streamflow-finance/js-sdk/", "main": "dist/index.js", diff --git a/packages/distributor/solana/client.ts b/packages/distributor/solana/client.ts index 74aeab93..75bb0b2d 100644 --- a/packages/distributor/solana/client.ts +++ b/packages/distributor/solana/client.ts @@ -133,8 +133,12 @@ export default class SolanaDistributorClient { ), ); - const { tx, hash } = await prepareTransaction(this.connection, ixs, invoker.publicKey); - const signature = await wrappedSignAndExecuteTransaction(this.connection, invoker, tx, hash); + const { tx, hash, context } = await prepareTransaction(this.connection, ixs, invoker.publicKey); + const signature = await wrappedSignAndExecuteTransaction(this.connection, invoker, tx, { + hash, + context, + commitment: this.getCommitment(), + }); return { ixs, @@ -192,8 +196,12 @@ export default class SolanaDistributorClient { ixs.push(claimLocked(accounts, this.programId)); } - const { tx, hash } = await prepareTransaction(this.connection, ixs, invoker.publicKey); - const signature = await wrappedSignAndExecuteTransaction(this.connection, invoker, tx, hash); + const { tx, hash, context } = await prepareTransaction(this.connection, ixs, invoker.publicKey); + const signature = await wrappedSignAndExecuteTransaction(this.connection, invoker, tx, { + hash, + context, + commitment: this.getCommitment(), + }); return { ixs, txId: signature }; } @@ -230,8 +238,12 @@ export default class SolanaDistributorClient { ixs.push(clawback(accounts, this.programId)); - const { tx, hash } = await prepareTransaction(this.connection, ixs, invoker.publicKey); - const signature = await wrappedSignAndExecuteTransaction(this.connection, invoker, tx, hash); + const { tx, hash, context } = await prepareTransaction(this.connection, ixs, invoker.publicKey); + const signature = await wrappedSignAndExecuteTransaction(this.connection, invoker, tx, { + hash, + context, + commitment: this.getCommitment(), + }); return { ixs, txId: signature }; } diff --git a/packages/distributor/solana/utils.ts b/packages/distributor/solana/utils.ts index 506b8f6f..80b6a652 100644 --- a/packages/distributor/solana/utils.ts +++ b/packages/distributor/solana/utils.ts @@ -1,7 +1,7 @@ import { SignerWalletAdapter } from "@solana/wallet-adapter-base"; -import { BlockhashWithExpiryBlockHeight, Connection, Keypair, PublicKey, Transaction } from "@solana/web3.js"; +import { Connection, Keypair, PublicKey, Transaction, VersionedTransaction } from "@solana/web3.js"; import { ContractError } from "@streamflow/common"; -import { signAndExecuteTransaction } from "@streamflow/common/solana"; +import { ConfirmationParams, signAndExecuteTransaction } from "@streamflow/common/solana"; import { fromTxError } from "./generated/errors"; @@ -28,11 +28,11 @@ export function getClaimantStatusPda(programId: PublicKey, distributor: PublicKe export async function wrappedSignAndExecuteTransaction( connection: Connection, invoker: Keypair | SignerWalletAdapter, - tx: Transaction, - hash: BlockhashWithExpiryBlockHeight, + tx: Transaction | VersionedTransaction, + confirmationParams: ConfirmationParams, ): Promise { try { - return await signAndExecuteTransaction(connection, invoker, tx, hash); + return await signAndExecuteTransaction(connection, invoker, tx, confirmationParams); } catch (err: any) { if (err instanceof Error) { const parsed = fromTxError(err); diff --git a/packages/stream/package.json b/packages/stream/package.json index 9f30e3fd..3d261ff6 100644 --- a/packages/stream/package.json +++ b/packages/stream/package.json @@ -1,6 +1,6 @@ { "name": "@streamflow/stream", - "version": "6.0.3", + "version": "6.1.0", "description": "JavaScript SDK to interact with Streamflow protocol.", "homepage": "https://github.com/streamflow-finance/js-sdk/", "main": "dist/index.js", diff --git a/packages/stream/solana/StreamClient.ts b/packages/stream/solana/StreamClient.ts index 0a6c26f8..ad736a57 100644 --- a/packages/stream/solana/StreamClient.ts +++ b/packages/stream/solana/StreamClient.ts @@ -10,9 +10,10 @@ import { SystemProgram, SYSVAR_RENT_PUBKEY, TransactionInstruction, - Transaction, Commitment, ConnectionConfig, + TransactionMessage, + VersionedTransaction, } from "@solana/web3.js"; import { CheckAssociatedTokenAccountsData, @@ -135,14 +136,18 @@ export default class SolanaStreamClient extends BaseStreamClient { */ public async create(data: ICreateStreamData, extParams: ICreateStreamSolanaExt): Promise { const { ixs, metadata, metadataPubKey } = await this.prepareCreateInstructions(data, extParams); - const { tx, hash } = await prepareTransaction( + const { tx, hash, context } = await prepareTransaction( this.connection, ixs, extParams.sender.publicKey, undefined, metadata, ); - const signature = await signAndExecuteTransaction(this.connection, extParams.sender, tx, hash); + const signature = await signAndExecuteTransaction(this.connection, extParams.sender, tx, { + hash, + context, + commitment: this.getCommitment(), + }); return { ixs, txId: signature, metadataId: metadataPubKey.toBase58() }; } @@ -267,14 +272,18 @@ export default class SolanaStreamClient extends BaseStreamClient { */ public async createUnchecked(data: ICreateStreamData, extParams: ICreateStreamSolanaExt): Promise { const { ixs, metadata, metadataPubKey } = await this.prepareCreateUncheckedInstructions(data, extParams); - const { tx, hash } = await prepareTransaction( + const { tx, hash, context } = await prepareTransaction( this.connection, ixs, extParams.sender.publicKey, undefined, metadata, ); - const signature = await signAndExecuteTransaction(this.connection, extParams.sender, tx, hash); + const signature = await signAndExecuteTransaction(this.connection, extParams.sender, tx, { + hash, + context, + commitment: this.getCommitment(), + }); return { ixs, txId: signature, metadataId: metadataPubKey.toBase58() }; } @@ -428,16 +437,17 @@ export default class SolanaStreamClient extends BaseStreamClient { }); } - const hash = await this.connection.getLatestBlockhash(); + const { value: hash, context } = await this.connection.getLatestBlockhashAndContext(); for (const { ixs, metadata, recipient } of instructionsBatch) { - const tx = new Transaction({ - feePayer: sender.publicKey, - blockhash: hash.blockhash, - lastValidBlockHeight: hash.lastValidBlockHeight, - }).add(...ixs); + const messageV0 = new TransactionMessage({ + payerKey: sender.publicKey, + recentBlockhash: hash.blockhash, + instructions: ixs, + }).compileToV0Message(); + const tx = new VersionedTransaction(messageV0); if (metadata) { - tx.partialSign(metadata); + tx.sign([metadata]); } batch.push({ tx, recipient }); } @@ -446,11 +456,12 @@ export default class SolanaStreamClient extends BaseStreamClient { const totalDepositedAmount = recipients.reduce((acc, recipient) => recipient.amount.add(acc), new BN(0)); const nativeInstructions = await prepareWrappedAccount(this.connection, sender.publicKey, totalDepositedAmount); - const tx = new Transaction({ - feePayer: sender.publicKey, - blockhash: hash.blockhash, - lastValidBlockHeight: hash.lastValidBlockHeight, - }).add(...nativeInstructions); + const messageV0 = new TransactionMessage({ + payerKey: sender.publicKey, + recentBlockhash: hash.blockhash, + instructions: nativeInstructions, + }).compileToV0Message(); + const tx = new VersionedTransaction(messageV0); batch.push({ tx, @@ -459,13 +470,10 @@ export default class SolanaStreamClient extends BaseStreamClient { } const signedBatch: BatchItem[] = await signAllTransactionWithRecipients(sender, batch); - signedBatch.forEach((item, index) => { - item.tx.lastValidBlockHeight = batch[index].tx.lastValidBlockHeight; - }); if (isNative) { const prepareTx = signedBatch.pop(); - await sendAndConfirmStreamRawTransaction(this.connection, prepareTx!); + await sendAndConfirmStreamRawTransaction(this.connection, prepareTx!, { hash, context }); } const responses: PromiseSettledResult[] = []; @@ -473,13 +481,19 @@ export default class SolanaStreamClient extends BaseStreamClient { //if metadata pub keys were passed we should execute transaction sequentially //ephemeral signer need to be used first before proceeding with the next for (const batchTx of signedBatch) { - responses.push(...(await Promise.allSettled([sendAndConfirmStreamRawTransaction(this.connection, batchTx)]))); + responses.push( + ...(await Promise.allSettled([ + sendAndConfirmStreamRawTransaction(this.connection, batchTx, { hash, context }), + ])), + ); } } else { //send all transactions in parallel and wait for them to settle. //it allows to speed up the process of sending transactions //we then filter all promise responses and handle failed transactions - const batchTransactionsCalls = signedBatch.map((el) => sendAndConfirmStreamRawTransaction(this.connection, el)); + const batchTransactionsCalls = signedBatch.map((el) => + sendAndConfirmStreamRawTransaction(this.connection, el, { hash, context }), + ); responses.push(...(await Promise.allSettled(batchTransactionsCalls))); } @@ -507,8 +521,12 @@ export default class SolanaStreamClient extends BaseStreamClient { extParams: IInteractStreamSolanaExt, ): Promise { const ixs: TransactionInstruction[] = await this.prepareWithdrawInstructions({ id, amount }, extParams); - const { tx, hash } = await prepareTransaction(this.connection, ixs, extParams.invoker.publicKey); - const signature = await signAndExecuteTransaction(this.connection, extParams.invoker, tx, hash); + const { tx, hash, context } = await prepareTransaction(this.connection, ixs, extParams.invoker.publicKey); + const signature = await signAndExecuteTransaction(this.connection, extParams.invoker, tx, { + hash, + context, + commitment: this.getCommitment(), + }); return { ixs, txId: signature }; } @@ -564,8 +582,12 @@ export default class SolanaStreamClient extends BaseStreamClient { */ public async cancel({ id }: ICancelData, extParams: IInteractStreamSolanaExt): Promise { const ixs = await this.prepareCancelInstructions({ id }, extParams); - const { tx, hash } = await prepareTransaction(this.connection, ixs, extParams.invoker.publicKey); - const signature = await signAndExecuteTransaction(this.connection, extParams.invoker, tx, hash); + const { tx, hash, context } = await prepareTransaction(this.connection, ixs, extParams.invoker.publicKey); + const signature = await signAndExecuteTransaction(this.connection, extParams.invoker, tx, { + hash, + context, + commitment: this.getCommitment(), + }); return { ixs, txId: signature }; } @@ -628,8 +650,12 @@ export default class SolanaStreamClient extends BaseStreamClient { extParams: IInteractStreamSolanaExt, ): Promise { const ixs: TransactionInstruction[] = await this.prepareTransferInstructions({ id, newRecipient }, extParams); - const { tx, hash } = await prepareTransaction(this.connection, ixs, extParams.invoker.publicKey); - const signature = await signAndExecuteTransaction(this.connection, extParams.invoker, tx, hash); + const { tx, hash, context } = await prepareTransaction(this.connection, ixs, extParams.invoker.publicKey); + const signature = await signAndExecuteTransaction(this.connection, extParams.invoker, tx, { + hash, + context, + commitment: this.getCommitment(), + }); return { ixs, txId: signature }; } @@ -682,8 +708,12 @@ export default class SolanaStreamClient extends BaseStreamClient { */ public async topup({ id, amount }: ITopUpData, extParams: ITopUpStreamSolanaExt): Promise { const ixs: TransactionInstruction[] = await this.prepareTopupInstructions({ id, amount }, extParams); - const { tx, hash } = await prepareTransaction(this.connection, ixs, extParams.invoker.publicKey); - const signature = await signAndExecuteTransaction(this.connection, extParams.invoker, tx, hash); + const { tx, hash, context } = await prepareTransaction(this.connection, ixs, extParams.invoker.publicKey); + const signature = await signAndExecuteTransaction(this.connection, extParams.invoker, tx, { + hash, + context, + commitment: this.getCommitment(), + }); return { ixs, txId: signature }; } @@ -799,8 +829,12 @@ export default class SolanaStreamClient extends BaseStreamClient { */ public async update(data: IUpdateData, extParams: IInteractStreamSolanaExt): Promise { const ixs = await this.prepareUpdateInstructions(data, extParams); - const { tx, hash } = await prepareTransaction(this.connection, ixs, extParams.invoker.publicKey); - const signature = await signAndExecuteTransaction(this.connection, extParams.invoker, tx, hash); + const { tx, hash, context } = await prepareTransaction(this.connection, ixs, extParams.invoker.publicKey); + const signature = await signAndExecuteTransaction(this.connection, extParams.invoker, tx, { + hash, + context, + commitment: this.getCommitment(), + }); return { ixs, @@ -866,7 +900,8 @@ export default class SolanaStreamClient extends BaseStreamClient { } public extractErrorCode(err: Error): string | null { - return extractSolanaErrorCode(err.toString() ?? "Unknown error!"); + const logs = "logs" in err && Array.isArray(err.logs) ? err.logs : undefined; + return extractSolanaErrorCode(err.toString() ?? "Unknown error!", logs); } /** diff --git a/packages/stream/solana/types.ts b/packages/stream/solana/types.ts index 6fcf41e4..fe2165e4 100644 --- a/packages/stream/solana/types.ts +++ b/packages/stream/solana/types.ts @@ -1,5 +1,5 @@ import { SignerWalletAdapter } from "@solana/wallet-adapter-base"; -import { AccountInfo, PublicKey, Keypair, Transaction } from "@solana/web3.js"; +import { AccountInfo, PublicKey, Keypair, VersionedTransaction } from "@solana/web3.js"; import { ITransactionSolanaExt } from "@streamflow/common/solana"; import BN from "bn.js"; @@ -229,7 +229,7 @@ export interface MetadataRecipientHashMap { export interface BatchItem { recipient: string; - tx: Transaction; + tx: VersionedTransaction; } export interface BatchItemSuccess extends BatchItem { diff --git a/packages/stream/solana/utils.ts b/packages/stream/solana/utils.ts index f1082417..ac84f12a 100644 --- a/packages/stream/solana/utils.ts +++ b/packages/stream/solana/utils.ts @@ -1,8 +1,7 @@ import { SignerWalletAdapter } from "@solana/wallet-adapter-base"; -import { BlockheightBasedTransactionConfirmationStrategy, Connection, Keypair, PublicKey } from "@solana/web3.js"; -import { executeTransaction, isSignerKeypair, isSignerWallet } from "@streamflow/common/solana"; +import { Connection, Keypair, PublicKey } from "@solana/web3.js"; +import { ConfirmationParams, executeTransaction, isSignerKeypair, isSignerWallet } from "@streamflow/common/solana"; import BN from "bn.js"; -import bs58 from "bs58"; import { streamLayout } from "./layout"; import { DecodedStream, BatchItem, BatchItemResult } from "./types"; @@ -75,7 +74,7 @@ export async function signAllTransactionWithRecipients( if (isKeypair) { return items.map((t) => { - t.tx.partialSign(sender); + t.tx.sign([sender]); return { tx: t.tx, recipient: t.recipient }; }); } else if (isWallet) { @@ -94,34 +93,36 @@ export async function signAllTransactionWithRecipients( * Sign passed BatchItems with wallet request or KeyPair * @param {Connection} connection - Solana web3 connection object. * @param {BatchItem} batchItem - Signed transaction ready to be send. + * @param {ConfirmationParams} confirmationParams - Confirmation Params that will be used for execution * @return {Promise} - Returns settled transaction item */ export async function sendAndConfirmStreamRawTransaction( connection: Connection, batchItem: BatchItem, + confirmationParams: ConfirmationParams, ): Promise { try { - const { lastValidBlockHeight, signature, recentBlockhash } = batchItem.tx; - if (!lastValidBlockHeight || !signature || !recentBlockhash) - throw { recipient: batchItem.recipient, error: "no recent blockhash" }; - - const confirmationStrategy: BlockheightBasedTransactionConfirmationStrategy = { - lastValidBlockHeight, - signature: bs58.encode(signature), - blockhash: recentBlockhash, - }; - const completedTxSignature = await executeTransaction(connection, batchItem.tx, confirmationStrategy); + const completedTxSignature = await executeTransaction(connection, batchItem.tx, confirmationParams); return { ...batchItem, signature: completedTxSignature }; } catch (error: any) { throw { recipient: batchItem.recipient, - error: error?.error ?? error?.message ?? error.toString(), + error, }; } } -export function extractSolanaErrorCode(errorText: string): string | null { - const match = SOLANA_ERROR_MATCH_REGEX.exec(errorText); +export function extractSolanaErrorCode(errorText: string, logs?: string[]): string | null { + let match = SOLANA_ERROR_MATCH_REGEX.exec(errorText); + + if (!match && logs) { + for (const logLine of logs) { + match = SOLANA_ERROR_MATCH_REGEX.exec(logLine); + if (match !== null) { + break; + } + } + } if (!match) { return null;