From c841ed5093a8e4eea033f26ed43ad5b0e632b219 Mon Sep 17 00:00:00 2001 From: Ben Guidarelli Date: Wed, 3 Jan 2024 09:02:04 -0500 Subject: [PATCH 1/5] add getMessageFee to core protocol, add handlers for some Solana simulate errors --- platforms/solana/src/platform.ts | 68 ++++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 8 deletions(-) diff --git a/platforms/solana/src/platform.ts b/platforms/solana/src/platform.ts index db3a9dc91..c65c187e0 100644 --- a/platforms/solana/src/platform.ts +++ b/platforms/solana/src/platform.ts @@ -22,6 +22,8 @@ import { ParsedAccountData, PublicKey, SendOptions, + SendTransactionError, + TransactionExpiredBlockheightExceededError, } from '@solana/web3.js'; import { SolanaAddress, SolanaZeroAddress } from './address'; import { @@ -160,6 +162,52 @@ export class SolanaPlatform extends PlatformContext< return balancesArr.reduce((obj, item) => Object.assign(obj, item), {}); } + // Handles retrying a Transaction if the error is deemed to be + // recoverable. Currently handles: + // - Blockhash not found (blockhash too new for the node we submitted to) + // - Not enough bytes (storage account not seen yet) + + private static async sendWithRetry( + rpc: Connection, + stxns: SignedTx, + opts: SendOptions, + retries: number = 3, + ): Promise { + // Shouldnt get hit but just in case + if (!retries) throw new Error('Too many retries'); + + try { + const txid = await rpc.sendRawTransaction(stxns.tx, opts); + return txid; + } catch (e) { + retries -= 1; + if (!retries) throw e; + + // Would require re-signing, for now bail + if (e instanceof TransactionExpiredBlockheightExceededError) throw e; + + // Only handle SendTransactionError + if (!(e instanceof SendTransactionError)) throw e; + const emsg = e.message; + + // Only handle simulation errors + if (!emsg.includes('Transaction simulation failed')) throw e; + + // Blockhash not found _yet_ + if (emsg.includes('Blockhash not found')) + return this.sendWithRetry(rpc, stxns, opts, retries); + + // Find the log message with the error details + const loggedErr = e.logs.find((log) => + log.startsWith('Program log: Error: '), + ); + + // Probably caused by storage account not seen yet + if (loggedErr && loggedErr.includes('Not enough bytes')) + return this.sendWithRetry(rpc, stxns, opts, retries); + } + } + static async sendWait( chain: Chain, rpc: Connection, @@ -168,14 +216,16 @@ export class SolanaPlatform extends PlatformContext< ): Promise { const { blockhash, lastValidBlockHeight } = await this.latestBlock(rpc); - // Set the commitment level to match the rpc commitment level - // otherwise, it defaults to finalized - if (!opts) opts = { preflightCommitment: rpc.commitment }; - const txhashes = await Promise.all( - stxns.map((stxn) => { - return rpc.sendRawTransaction(stxn, opts); - }), + stxns.map((stxn) => + this.sendWithRetry( + rpc, + stxn, + // Set the commitment level to match the rpc commitment level + // otherwise, it defaults to finalized + opts ?? { preflightCommitment: rpc.commitment }, + ), + ), ); await Promise.all( @@ -198,11 +248,13 @@ export class SolanaPlatform extends PlatformContext< rpc: Connection, commitment?: Commitment, ): Promise<{ blockhash: string; lastValidBlockHeight: number }> { + // Use finalized to prevent blockhash not found errors + // Note: this may mean we have less time to submit transactions? return rpc.getLatestBlockhash(commitment ?? 'finalized'); } static async getLatestBlock(rpc: Connection): Promise { - const { lastValidBlockHeight } = await this.latestBlock(rpc); + const { lastValidBlockHeight } = await this.latestBlock(rpc, 'confirmed'); return lastValidBlockHeight; } From d403d62e0e3a1314d559c331971c02ef7413c52f Mon Sep 17 00:00:00 2001 From: Ben Guidarelli Date: Wed, 3 Jan 2024 09:26:33 -0500 Subject: [PATCH 2/5] add fee transfer ix to message example --- platforms/solana/protocols/core/src/core.ts | 3 +++ .../solana/protocols/tokenBridge/src/tokenBridge.ts | 3 ++- platforms/solana/src/platform.ts | 11 ++++++----- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/platforms/solana/protocols/core/src/core.ts b/platforms/solana/protocols/core/src/core.ts index 1c7a21ee3..f4b54a3cf 100644 --- a/platforms/solana/protocols/core/src/core.ts +++ b/platforms/solana/protocols/core/src/core.ts @@ -36,8 +36,10 @@ import { createPostVaaInstruction, createReadOnlyWormholeProgramInterface, createVerifySignaturesInstructions, + createBridgeFeeTransferInstruction, derivePostedVaaKey, getWormholeBridgeData, + BridgeData, } from './utils'; const SOLANA_SEQ_LOG = 'Program log: Sequence: '; @@ -82,6 +84,7 @@ export class SolanaWormholeCore throw new Error( `Network mismatch for chain ${chain}: ${conf.network} != ${network}`, ); + return new SolanaWormholeCore( network as N, chain, diff --git a/platforms/solana/protocols/tokenBridge/src/tokenBridge.ts b/platforms/solana/protocols/tokenBridge/src/tokenBridge.ts index 3605705bb..b960867b7 100644 --- a/platforms/solana/protocols/tokenBridge/src/tokenBridge.ts +++ b/platforms/solana/protocols/tokenBridge/src/tokenBridge.ts @@ -217,11 +217,12 @@ export class SolanaTokenBridge const nonce = 0; const msgFee = await this.coreBridge.getMessageFee(); - const transferIx = await coreUtils.createBridgeFeeTransferInstruction( + const transferIx = coreUtils.createBridgeFeeTransferInstruction( this.coreBridge.address, senderAddress, msgFee, ); + const messageKey = Keypair.generate(); const attestIx = createAttestTokenInstruction( this.connection, diff --git a/platforms/solana/src/platform.ts b/platforms/solana/src/platform.ts index c65c187e0..c06f62f33 100644 --- a/platforms/solana/src/platform.ts +++ b/platforms/solana/src/platform.ts @@ -166,10 +166,9 @@ export class SolanaPlatform extends PlatformContext< // recoverable. Currently handles: // - Blockhash not found (blockhash too new for the node we submitted to) // - Not enough bytes (storage account not seen yet) - private static async sendWithRetry( rpc: Connection, - stxns: SignedTx, + stxn: SignedTx, opts: SendOptions, retries: number = 3, ): Promise { @@ -177,7 +176,7 @@ export class SolanaPlatform extends PlatformContext< if (!retries) throw new Error('Too many retries'); try { - const txid = await rpc.sendRawTransaction(stxns.tx, opts); + const txid = await rpc.sendRawTransaction(stxn, opts); return txid; } catch (e) { retries -= 1; @@ -195,7 +194,7 @@ export class SolanaPlatform extends PlatformContext< // Blockhash not found _yet_ if (emsg.includes('Blockhash not found')) - return this.sendWithRetry(rpc, stxns, opts, retries); + return this.sendWithRetry(rpc, stxn, opts, retries); // Find the log message with the error details const loggedErr = e.logs.find((log) => @@ -204,7 +203,9 @@ export class SolanaPlatform extends PlatformContext< // Probably caused by storage account not seen yet if (loggedErr && loggedErr.includes('Not enough bytes')) - return this.sendWithRetry(rpc, stxns, opts, retries); + return this.sendWithRetry(rpc, stxn, opts, retries); + + throw e; } } From c63fcd2f83b53bfd0aa45797b92a3b14560bf730 Mon Sep 17 00:00:00 2001 From: Ben Guidarelli Date: Wed, 3 Jan 2024 13:31:33 -0500 Subject: [PATCH 3/5] update solana to pass back all signers involved and require the sol signer set the recent blockhash --- .../solana/protocols/cctp/src/circleBridge.ts | 12 +- platforms/solana/protocols/core/src/core.ts | 29 ++-- .../tokenBridge/src/automaticTokenBridge.ts | 15 +- .../protocols/tokenBridge/src/tokenBridge.ts | 69 ++++------ platforms/solana/src/platform.ts | 70 ++-------- platforms/solana/src/testing/debug.ts | 14 ++ platforms/solana/src/testing/index.ts | 1 + platforms/solana/src/testing/sendSigner.ts | 130 +++++++++++++----- platforms/solana/src/testing/signer.ts | 34 ++--- platforms/solana/src/unsignedTransaction.ts | 9 +- 10 files changed, 202 insertions(+), 181 deletions(-) create mode 100644 platforms/solana/src/testing/debug.ts diff --git a/platforms/solana/protocols/cctp/src/circleBridge.ts b/platforms/solana/protocols/cctp/src/circleBridge.ts index 6f20cbef1..837388413 100644 --- a/platforms/solana/protocols/cctp/src/circleBridge.ts +++ b/platforms/solana/protocols/cctp/src/circleBridge.ts @@ -18,6 +18,7 @@ import { SolanaChains, SolanaPlatform, SolanaPlatformType, + SolanaTransaction, SolanaUnsignedTransaction, } from '@wormhole-foundation/connect-sdk-solana'; import { MessageTransmitter, TokenMessenger } from '.'; @@ -105,13 +106,10 @@ export class SolanaCircleBridge senderPk, ); - const { blockhash } = await SolanaPlatform.latestBlock(this.connection); const transaction = new Transaction(); - transaction.recentBlockhash = blockhash; transaction.feePayer = senderPk; transaction.add(ix); - - yield this.createUnsignedTx(transaction, 'CircleBridge.Redeem'); + yield this.createUnsignedTx({ transaction }, 'CircleBridge.Redeem'); } async *transfer( @@ -140,13 +138,11 @@ export class SolanaCircleBridge amount, ); - const { blockhash } = await SolanaPlatform.latestBlock(this.connection); const transaction = new Transaction(); - transaction.recentBlockhash = blockhash; transaction.feePayer = senderPk; transaction.add(ix); - yield this.createUnsignedTx(transaction, 'CircleBridge.Transfer'); + yield this.createUnsignedTx({ transaction }, 'CircleBridge.Transfer'); } async isTransferCompleted(message: CircleBridge.Message): Promise { @@ -216,7 +212,7 @@ export class SolanaCircleBridge } private createUnsignedTx( - txReq: Transaction, + txReq: SolanaTransaction, description: string, parallelizable: boolean = false, ): SolanaUnsignedTransaction { diff --git a/platforms/solana/protocols/core/src/core.ts b/platforms/solana/protocols/core/src/core.ts index f4b54a3cf..50109ec65 100644 --- a/platforms/solana/protocols/core/src/core.ts +++ b/platforms/solana/protocols/core/src/core.ts @@ -15,6 +15,7 @@ import { SolanaPlatform, SolanaPlatformType, SolanaUnsignedTransaction, + SolanaTransaction, } from '@wormhole-foundation/connect-sdk-solana'; import { ChainId, @@ -129,24 +130,20 @@ export class SolanaWormholeCore fee, ); - const { blockhash } = await SolanaPlatform.latestBlock(this.connection); const transaction = new Transaction(); - transaction.recentBlockhash = blockhash; transaction.feePayer = payer; transaction.add(feeTransferIx, postMsgIx); - transaction.partialSign(messageAccount); - - yield this.createUnsignedTx(transaction, 'Core.PublishMessage'); + yield this.createUnsignedTx( + { transaction, signers: [messageAccount] }, + 'Core.PublishMessage', + ); } async *verifyMessage(sender: AnySolanaAddress, vaa: VAA) { yield* this.postVaa(sender, vaa); } - async *postVaa(sender: AnySolanaAddress, vaa: VAA, blockhash?: string) { - if (!blockhash) - ({ blockhash } = await SolanaPlatform.latestBlock(this.connection)); - + async *postVaa(sender: AnySolanaAddress, vaa: VAA) { const postedVaaAddress = derivePostedVaaKey( this.coreBridge.programId, Buffer.from(vaa.hash), @@ -173,11 +170,12 @@ export class SolanaWormholeCore const verifySigTx = new Transaction().add( ...verifySignaturesInstructions.slice(i, i + 2), ); - verifySigTx.recentBlockhash = blockhash; verifySigTx.feePayer = senderAddr; - verifySigTx.partialSign(signatureSet); - - yield this.createUnsignedTx(verifySigTx, 'Core.VerifySignature', true); + yield this.createUnsignedTx( + { transaction: verifySigTx, signers: [signatureSet] }, + 'Core.VerifySignature', + true, + ); } // Finally create the VAA posting transaction @@ -190,10 +188,9 @@ export class SolanaWormholeCore signatureSet.publicKey, ), ); - postVaaTx.recentBlockhash = blockhash; postVaaTx.feePayer = senderAddr; - yield this.createUnsignedTx(postVaaTx, 'Core.PostVAA'); + yield this.createUnsignedTx({ transaction: postVaaTx }, 'Core.PostVAA'); } static parseSequenceFromLog( @@ -330,7 +327,7 @@ export class SolanaWormholeCore } private createUnsignedTx( - txReq: Transaction, + txReq: SolanaTransaction, description: string, parallelizable: boolean = false, ): SolanaUnsignedTransaction { diff --git a/platforms/solana/protocols/tokenBridge/src/automaticTokenBridge.ts b/platforms/solana/protocols/tokenBridge/src/automaticTokenBridge.ts index d233685c7..9e26a174f 100644 --- a/platforms/solana/protocols/tokenBridge/src/automaticTokenBridge.ts +++ b/platforms/solana/protocols/tokenBridge/src/automaticTokenBridge.ts @@ -16,6 +16,7 @@ import { SolanaChains, SolanaPlatform, SolanaPlatformType, + SolanaTransaction, SolanaUnsignedTransaction, } from '@wormhole-foundation/connect-sdk-solana'; @@ -173,18 +174,18 @@ export class SolanaAutomaticTokenBridge< nonce, ); - const { blockhash } = await SolanaPlatform.latestBlock(this.connection); - transaction.add(transferIx); - transaction.recentBlockhash = blockhash; transaction.feePayer = senderAddress; - yield this.createUnsignedTx(transaction, 'AutomaticTokenBridge.Transfer'); + yield this.createUnsignedTx( + { transaction }, + 'AutomaticTokenBridge.Transfer', + ); } async *redeem(sender: AccountAddress, vaa: AutomaticTokenBridge.VAA) { - const redeemTx = new Transaction(); - yield this.createUnsignedTx(redeemTx, 'AutomaticTokenBridge.Redeem'); + const transaction = new Transaction(); + yield this.createUnsignedTx({ transaction }, 'AutomaticTokenBridge.Redeem'); throw new Error('Method not implemented.'); } @@ -327,7 +328,7 @@ export class SolanaAutomaticTokenBridge< } private createUnsignedTx( - txReq: Transaction, + txReq: SolanaTransaction, description: string, parallelizable: boolean = false, ): SolanaUnsignedTransaction { diff --git a/platforms/solana/protocols/tokenBridge/src/tokenBridge.ts b/platforms/solana/protocols/tokenBridge/src/tokenBridge.ts index b960867b7..87c0cb906 100644 --- a/platforms/solana/protocols/tokenBridge/src/tokenBridge.ts +++ b/platforms/solana/protocols/tokenBridge/src/tokenBridge.ts @@ -21,6 +21,7 @@ import { SolanaChains, SolanaPlatform, SolanaPlatformType, + SolanaTransaction, SolanaUnsignedTransaction, } from '@wormhole-foundation/connect-sdk-solana'; import { @@ -211,7 +212,6 @@ export class SolanaTokenBridge ): AsyncGenerator> { if (!payer) throw new Error('Payer required to create attestation'); - const { blockhash } = await SolanaPlatform.latestBlock(this.connection); const senderAddress = new SolanaAddress(payer).unwrap(); // TODO: createNonce().readUInt32LE(0); const nonce = 0; @@ -235,11 +235,11 @@ export class SolanaTokenBridge ); const transaction = new Transaction().add(transferIx, attestIx); - transaction.recentBlockhash = blockhash; transaction.feePayer = senderAddress; - transaction.partialSign(messageKey); - - yield this.createUnsignedTx(transaction, 'Solana.AttestToken'); + yield this.createUnsignedTx( + { transaction, signers: [messageKey] }, + 'Solana.AttestToken', + ); } async *submitAttestation( @@ -248,11 +248,10 @@ export class SolanaTokenBridge ): AsyncGenerator> { if (!payer) throw new Error('Payer required to create attestation'); - const { blockhash } = await SolanaPlatform.latestBlock(this.connection); const senderAddress = new SolanaAddress(payer).unwrap(); // Yield transactions to verify sigs and post the VAA - yield* this.coreBridge.postVaa(senderAddress, vaa, blockhash); + yield* this.coreBridge.postVaa(senderAddress, vaa); // Now yield the transaction to actually create the token const transaction = new Transaction().add( @@ -264,10 +263,9 @@ export class SolanaTokenBridge vaa, ), ); - transaction.recentBlockhash = blockhash; transaction.feePayer = senderAddress; - yield this.createUnsignedTx(transaction, 'Solana.CreateWrapped'); + yield this.createUnsignedTx({ transaction }, 'Solana.CreateWrapped'); } private async transferSol( @@ -278,7 +276,6 @@ export class SolanaTokenBridge ): Promise> { // https://github.com/wormhole-foundation/wormhole-connect/blob/development/sdk/src/contexts/solana/context.ts#L245 - const { blockhash } = await SolanaPlatform.latestBlock(this.connection); const senderAddress = new SolanaAddress(sender).unwrap(); // TODO: the payer can actually be different from the sender. We need to allow the user to pass in an optional payer @@ -368,7 +365,6 @@ export class SolanaTokenBridge ); const transaction = new Transaction(); - transaction.recentBlockhash = blockhash; transaction.feePayer = payerPublicKey; transaction.add( createAncillaryAccountIx, @@ -378,9 +374,10 @@ export class SolanaTokenBridge tokenBridgeTransferIx, closeAccountIx, ); - transaction.partialSign(message, ancillaryKeypair); - - return this.createUnsignedTx(transaction, 'TokenBridge.TransferNative'); + return this.createUnsignedTx( + { transaction, signers: [message, ancillaryKeypair] }, + 'TokenBridge.TransferNative', + ); } async *transfer( @@ -397,7 +394,6 @@ export class SolanaTokenBridge return; } - const { blockhash } = await SolanaPlatform.latestBlock(this.connection); const tokenAddress = new SolanaAddress(token).unwrap(); const senderAddress = new SolanaAddress(sender).unwrap(); const senderTokenAddress = await getAssociatedTokenAddress( @@ -497,17 +493,16 @@ export class SolanaTokenBridge tokenBridgeTransferIx, ); - transaction.recentBlockhash = blockhash; transaction.feePayer = senderAddress; - transaction.partialSign(message); - - yield this.createUnsignedTx(transaction, 'TokenBridge.TransferTokens'); + yield this.createUnsignedTx( + { transaction, signers: [message] }, + 'TokenBridge.TransferTokens', + ); } private async *redeemAndUnwrap( sender: AnySolanaAddress, vaa: TokenBridge.TransferVAA, - blockhash: string, ) { // sender, fee payer const payerPublicKey = new SolanaAddress(sender).unwrap(); @@ -566,7 +561,6 @@ export class SolanaTokenBridge ); const transaction = new Transaction(); - transaction.recentBlockhash = blockhash; transaction.feePayer = payerPublicKey; transaction.add( completeTransferIx, @@ -575,15 +569,13 @@ export class SolanaTokenBridge balanceTransferIx, closeAccountIx, ); - transaction.partialSign(ancillaryKeypair); - yield this.createUnsignedTx(transaction, 'TokenBridge.RedeemAndUnwrap'); + yield this.createUnsignedTx( + { transaction, signers: [ancillaryKeypair] }, + 'TokenBridge.RedeemAndUnwrap', + ); } - private async *createAta( - sender: AnySolanaAddress, - token: AnySolanaAddress, - blockhash: string, - ) { + private async *createAta(sender: AnySolanaAddress, token: AnySolanaAddress) { const senderAddress = new SolanaAddress(sender).unwrap(); const tokenAddress = new SolanaAddress(token).unwrap(); @@ -592,7 +584,7 @@ export class SolanaTokenBridge // If the ata doesn't exist yet, create it const acctInfo = await this.connection.getAccountInfo(ata); if (acctInfo === null) { - const ataCreationTx = new Transaction().add( + const transaction = new Transaction().add( createAssociatedTokenAccountInstruction( senderAddress, ata, @@ -600,9 +592,8 @@ export class SolanaTokenBridge tokenAddress, ), ); - ataCreationTx.feePayer = senderAddress; - ataCreationTx.recentBlockhash = blockhash; - yield this.createUnsignedTx(ataCreationTx, 'Redeem.CreateATA'); + transaction.feePayer = senderAddress; + yield this.createUnsignedTx({ transaction }, 'Redeem.CreateATA'); } } @@ -611,8 +602,6 @@ export class SolanaTokenBridge vaa: TokenBridge.TransferVAA, unwrapNative: boolean = true, ) { - const { blockhash } = await SolanaPlatform.latestBlock(this.connection); - // Find the token address local to this chain const nativeAddress = vaa.payload.token.chain === this.chain @@ -620,10 +609,10 @@ export class SolanaTokenBridge : (await this.getWrappedAsset(vaa.payload.token)).toUniversalAddress(); // Create an ATA if necessary - yield* this.createAta(sender, nativeAddress, blockhash); + yield* this.createAta(sender, nativeAddress); // Post the VAA if necessary - yield* this.coreBridge.postVaa(sender, vaa, blockhash); + yield* this.coreBridge.postVaa(sender, vaa); // redeem vaa and unwrap to native sol from wrapped sol if (unwrapNative) { @@ -635,7 +624,7 @@ export class SolanaTokenBridge wrappedNative.toUint8Array(), ) ) { - yield* this.redeemAndUnwrap(sender, vaa, blockhash); + yield* this.redeemAndUnwrap(sender, vaa); return; } } @@ -656,14 +645,12 @@ export class SolanaTokenBridge vaa, ), ); - - transaction.recentBlockhash = blockhash; transaction.feePayer = senderAddress; - yield this.createUnsignedTx(transaction, 'Solana.RedeemTransfer'); + yield this.createUnsignedTx({ transaction }, 'Solana.RedeemTransfer'); } private createUnsignedTx( - txReq: Transaction, + txReq: SolanaTransaction, description: string, parallelizable: boolean = false, ): SolanaUnsignedTransaction { diff --git a/platforms/solana/src/platform.ts b/platforms/solana/src/platform.ts index c06f62f33..1d28eab4a 100644 --- a/platforms/solana/src/platform.ts +++ b/platforms/solana/src/platform.ts @@ -19,11 +19,10 @@ import { TOKEN_PROGRAM_ID } from '@solana/spl-token'; import { Commitment, Connection, + ConnectionConfig, ParsedAccountData, PublicKey, SendOptions, - SendTransactionError, - TransactionExpiredBlockheightExceededError, } from '@solana/web3.js'; import { SolanaAddress, SolanaZeroAddress } from './address'; import { @@ -51,10 +50,13 @@ export class SolanaPlatform extends PlatformContext< getRpc( chain: C, - commitment: Commitment = 'confirmed', + config: ConnectionConfig = { + commitment: 'confirmed', + disableRetryOnRateLimit: true, + }, ): Connection { if (chain in this.config) - return new Connection(this.config[chain]!.rpc, commitment); + return new Connection(this.config[chain]!.rpc, config); throw new Error('No configuration available for chain: ' + chain); } @@ -162,53 +164,6 @@ export class SolanaPlatform extends PlatformContext< return balancesArr.reduce((obj, item) => Object.assign(obj, item), {}); } - // Handles retrying a Transaction if the error is deemed to be - // recoverable. Currently handles: - // - Blockhash not found (blockhash too new for the node we submitted to) - // - Not enough bytes (storage account not seen yet) - private static async sendWithRetry( - rpc: Connection, - stxn: SignedTx, - opts: SendOptions, - retries: number = 3, - ): Promise { - // Shouldnt get hit but just in case - if (!retries) throw new Error('Too many retries'); - - try { - const txid = await rpc.sendRawTransaction(stxn, opts); - return txid; - } catch (e) { - retries -= 1; - if (!retries) throw e; - - // Would require re-signing, for now bail - if (e instanceof TransactionExpiredBlockheightExceededError) throw e; - - // Only handle SendTransactionError - if (!(e instanceof SendTransactionError)) throw e; - const emsg = e.message; - - // Only handle simulation errors - if (!emsg.includes('Transaction simulation failed')) throw e; - - // Blockhash not found _yet_ - if (emsg.includes('Blockhash not found')) - return this.sendWithRetry(rpc, stxn, opts, retries); - - // Find the log message with the error details - const loggedErr = e.logs.find((log) => - log.startsWith('Program log: Error: '), - ); - - // Probably caused by storage account not seen yet - if (loggedErr && loggedErr.includes('Not enough bytes')) - return this.sendWithRetry(rpc, stxn, opts, retries); - - throw e; - } - } - static async sendWait( chain: Chain, rpc: Connection, @@ -216,11 +171,9 @@ export class SolanaPlatform extends PlatformContext< opts?: SendOptions, ): Promise { const { blockhash, lastValidBlockHeight } = await this.latestBlock(rpc); - const txhashes = await Promise.all( stxns.map((stxn) => - this.sendWithRetry( - rpc, + rpc.sendRawTransaction( stxn, // Set the commitment level to match the rpc commitment level // otherwise, it defaults to finalized @@ -229,7 +182,7 @@ export class SolanaPlatform extends PlatformContext< ), ); - await Promise.all( + const results = await Promise.all( txhashes.map((signature) => { return rpc.confirmTransaction( { @@ -242,6 +195,13 @@ export class SolanaPlatform extends PlatformContext< }), ); + const erroredTxs = results + .filter((result) => result.value.err) + .map((result) => result.value.err); + + if (erroredTxs.length > 0) + throw new Error(`Failed to confirm transaction: ${erroredTxs}`); + return txhashes; } diff --git a/platforms/solana/src/testing/debug.ts b/platforms/solana/src/testing/debug.ts new file mode 100644 index 000000000..67a853e32 --- /dev/null +++ b/platforms/solana/src/testing/debug.ts @@ -0,0 +1,14 @@ +import { Transaction } from '@solana/web3.js'; + +export function logTxDetails(transaction: Transaction) { + console.log(transaction.signatures); + console.log(transaction.feePayer); + transaction.instructions.forEach((ix) => { + console.log('Program', ix.programId.toBase58()); + console.log('Data: ', ix.data.toString('hex')); + console.log( + 'Keys: ', + ix.keys.map((k) => [k, k.pubkey.toBase58()]), + ); + }); +} diff --git a/platforms/solana/src/testing/index.ts b/platforms/solana/src/testing/index.ts index b53f40df4..768a910ee 100644 --- a/platforms/solana/src/testing/index.ts +++ b/platforms/solana/src/testing/index.ts @@ -13,6 +13,7 @@ export async function getSolanaSigner( return new SolanaSigner( chain, Keypair.fromSecretKey(encoding.b58.decode(privateKey)), + rpc, ); } diff --git a/platforms/solana/src/testing/sendSigner.ts b/platforms/solana/src/testing/sendSigner.ts index a0764dfc7..bbcd9c45a 100644 --- a/platforms/solana/src/testing/sendSigner.ts +++ b/platforms/solana/src/testing/sendSigner.ts @@ -1,4 +1,10 @@ -import { Connection, Keypair } from '@solana/web3.js'; +import { + Connection, + Keypair, + SendOptions, + SendTransactionError, + TransactionExpiredBlockheightExceededError, +} from '@solana/web3.js'; import { SignAndSendSigner, UnsignedTransaction, @@ -7,6 +13,7 @@ import { Network } from '@wormhole-foundation/sdk-base/src'; import { SolanaPlatform } from '../platform'; import { SolanaChains } from '../types'; import { SolanaUnsignedTransaction } from '../unsignedTransaction'; +import { logTxDetails } from './debug'; export class SolanaSendSigner< N extends Network, @@ -18,7 +25,12 @@ export class SolanaSendSigner< private _chain: C, private _keypair: Keypair, private _debug: boolean = false, - ) {} + private _sendOpts?: SendOptions, + ) { + this._sendOpts = this._sendOpts ?? { + preflightCommitment: this._rpc.commitment, + }; + } chain(): C { return this._chain; @@ -28,50 +40,96 @@ export class SolanaSendSigner< return this._keypair.publicKey.toBase58(); } - async signAndSend(tx: UnsignedTransaction[]): Promise { - const { blockhash, lastValidBlockHeight } = - await SolanaPlatform.latestBlock(this._rpc, 'finalized'); + // Handles retrying a Transaction if the error is deemed to be + // recoverable. Currently handles: + // - Blockhash not found + // - Not enough bytes (storage account not seen yet) + private retryable(e: any): boolean { + // Tx expired, set a new block hash and retry + if (e instanceof TransactionExpiredBlockheightExceededError) return true; + + // Besides tx expiry, only handle SendTransactionError + if (!(e instanceof SendTransactionError)) return false; + + // Only handle simulation errors + if (!e.message.includes('Transaction simulation failed')) return false; + + // Blockhash not found, similar to expired, resend with new blockhash + if (e.message.includes('Blockhash not found')) return true; + + // Find the log message with the error details + const loggedErr = e.logs.find((log) => + log.startsWith('Program log: Error: '), + ); + + // who knows + if (!loggedErr) return false; + + // Probably caused by storage account not seen yet + if (loggedErr.includes('Not enough bytes')) return true; + if (loggedErr.includes('Unexpected length of input')) return true; + + return false; + } - const txPromises: Promise[] = []; + async signAndSend(tx: UnsignedTransaction[]): Promise { + let { blockhash, lastValidBlockHeight } = await SolanaPlatform.latestBlock( + this._rpc, + 'finalized', + ); + const txids: string[] = []; for (const txn of tx) { - const { description, transaction } = txn as SolanaUnsignedTransaction< - N, - C - >; + const { + description, + transaction: { transaction, signers: extraSigners }, + } = txn as SolanaUnsignedTransaction; console.log(`Signing: ${description} for ${this.address()}`); - if (this._debug) { - console.log(transaction.signatures); - console.log(transaction.feePayer); - transaction.instructions.forEach((ix) => { - console.log('Program', ix.programId.toBase58()); - console.log('Data: ', ix.data.toString('hex')); - console.log( - 'Keys: ', - ix.keys.map((k) => [k, k.pubkey.toBase58()]), - ); - }); - } + if (this._debug) logTxDetails(transaction); + + // Try to send the transaction up to 5 times + for (let i = 0; i < 5; i++) { + try { + transaction.recentBlockhash = blockhash; + transaction.partialSign(this._keypair, ...(extraSigners ?? [])); - transaction.partialSign(this._keypair); + const txid = await this._rpc.sendRawTransaction( + transaction.serialize(), + this._sendOpts, + ); + txids.push(txid); + break; + } catch (e) { + if (!this.retryable(e)) throw e; - txPromises.push( - this._rpc.sendRawTransaction(transaction.serialize(), { - preflightCommitment: this._rpc.commitment, - }), - ); + // If it is retryable, we should grab a new block hash + ({ blockhash, lastValidBlockHeight } = + await SolanaPlatform.latestBlock(this._rpc, 'finalized')); + } + } } - const txids = await Promise.all(txPromises); // Wait for finalization - for (const signature of txids) { - await this._rpc.confirmTransaction({ - signature, - blockhash, - lastValidBlockHeight, - }); - } + const results = await Promise.all( + txids.map((signature) => + this._rpc.confirmTransaction( + { + signature, + blockhash, + lastValidBlockHeight, + }, + this._rpc.commitment, + ), + ), + ); + + const erroredTxs = results + .filter((result) => result.value.err) + .map((result) => result.value.err); + + if (erroredTxs.length > 0) + throw new Error(`Failed to confirm transaction: ${erroredTxs}`); return txids; } diff --git a/platforms/solana/src/testing/signer.ts b/platforms/solana/src/testing/signer.ts index 4c1a196af..fcc15fd26 100644 --- a/platforms/solana/src/testing/signer.ts +++ b/platforms/solana/src/testing/signer.ts @@ -1,10 +1,12 @@ -import { Keypair, Transaction } from '@solana/web3.js'; +import { Connection, Keypair } from '@solana/web3.js'; import { SignOnlySigner, UnsignedTransaction, } from '@wormhole-foundation/connect-sdk'; import { Network } from '@wormhole-foundation/sdk-base/src'; +import { SolanaPlatform } from '../platform'; import { SolanaChains } from '../types'; +import { logTxDetails } from './debug'; export class SolanaSigner implements SignOnlySigner @@ -12,6 +14,7 @@ export class SolanaSigner constructor( private _chain: C, private _keypair: Keypair, + private _rpc: Connection, private _debug: boolean = false, ) {} @@ -24,25 +27,24 @@ export class SolanaSigner } async sign(tx: UnsignedTransaction[]): Promise { + const { blockhash } = await SolanaPlatform.latestBlock( + this._rpc, + 'finalized', + ); + const signed = []; for (const txn of tx) { - const { description, transaction } = txn; + const { + description, + transaction: { transaction, signers: extraSigners }, + } = txn; + console.log(`Signing: ${description} for ${this.address()}`); - if (this._debug) { - const st = transaction as Transaction; - console.log(st.signatures); - console.log(st.feePayer); - st.instructions.forEach((ix) => { - console.log('Program', ix.programId.toBase58()); - console.log('Data: ', ix.data.toString('hex')); - ix.keys.forEach((k) => { - console.log(k, k.pubkey.toBase58()); - }); - }); - } - - transaction.partialSign(this._keypair); + if (this._debug) logTxDetails(transaction); + + transaction.recentBlockhash = blockhash; + transaction.partialSign(this._keypair, ...(extraSigners ?? [])); signed.push(transaction.serialize()); } return signed; diff --git a/platforms/solana/src/unsignedTransaction.ts b/platforms/solana/src/unsignedTransaction.ts index ecb030632..df718fc1b 100644 --- a/platforms/solana/src/unsignedTransaction.ts +++ b/platforms/solana/src/unsignedTransaction.ts @@ -1,14 +1,19 @@ -import { Transaction } from '@solana/web3.js'; +import { Keypair, Transaction } from '@solana/web3.js'; import { Network, UnsignedTransaction } from '@wormhole-foundation/connect-sdk'; import { SolanaChains } from './types'; +export type SolanaTransaction = { + transaction: Transaction; + signers?: Keypair[]; +}; + export class SolanaUnsignedTransaction< N extends Network, C extends SolanaChains = SolanaChains, > implements UnsignedTransaction { constructor( - readonly transaction: Transaction, + readonly transaction: SolanaTransaction, readonly network: N, readonly chain: C, readonly description: string, From 15ca3fce2073cafa87cdadc50cd5990bc65cdb06 Mon Sep 17 00:00:00 2001 From: Ben Guidarelli Date: Wed, 3 Jan 2024 13:51:31 -0500 Subject: [PATCH 4/5] fix tests --- .../__tests__/integration/tokenBridge.test.ts | 18 +++++++++--------- platforms/solana/protocols/core/src/core.ts | 2 -- .../protocols/tokenBridge/src/tokenBridge.ts | 2 +- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/platforms/solana/__tests__/integration/tokenBridge.test.ts b/platforms/solana/__tests__/integration/tokenBridge.test.ts index 2a6091a12..bc378ea34 100644 --- a/platforms/solana/__tests__/integration/tokenBridge.test.ts +++ b/platforms/solana/__tests__/integration/tokenBridge.test.ts @@ -204,7 +204,7 @@ describe('TokenBridge Tests', () => { expect(attestTx.chain).toEqual(chain); const { transaction } = attestTx; - expect(transaction.instructions).toHaveLength(2); + expect(transaction.transaction.instructions).toHaveLength(2); }); test('Submit Attestation', async () => { @@ -229,7 +229,7 @@ describe('TokenBridge Tests', () => { }); const submitAttestation = tb.submitAttestation(vaa, sender); - const allTxns = []; + const allTxns: SolanaUnsignedTransaction[] = []; for await (const atx of submitAttestation) { allTxns.push(atx); } @@ -237,9 +237,9 @@ describe('TokenBridge Tests', () => { const [verifySig, postVaa, create] = allTxns; // - expect(verifySig.transaction.instructions).toHaveLength(2); - expect(postVaa.transaction.instructions).toHaveLength(1); - expect(create.transaction.instructions).toHaveLength(1); + expect(verifySig.transaction.transaction.instructions).toHaveLength(2); + expect(postVaa.transaction.transaction.instructions).toHaveLength(1); + expect(create.transaction.transaction.instructions).toHaveLength(1); }); }); @@ -260,7 +260,7 @@ describe('TokenBridge Tests', () => { const xfer = tb.transfer(sender, recipient, token, amount, payload); expect(xfer).toBeTruthy(); - const allTxns = []; + const allTxns: SolanaUnsignedTransaction[] = []; for await (const tx of xfer) { allTxns.push(tx); } @@ -271,7 +271,7 @@ describe('TokenBridge Tests', () => { expect(xferTx!.chain).toEqual(chain); const { transaction } = xferTx; - expect(transaction.instructions).toHaveLength(6); + expect(transaction.transaction.instructions).toHaveLength(6); // ... }); @@ -285,7 +285,7 @@ describe('TokenBridge Tests', () => { ); expect(xfer).toBeTruthy(); - const allTxns = []; + const allTxns: SolanaUnsignedTransaction[] = []; for await (const tx of xfer) { allTxns.push(tx); } @@ -296,7 +296,7 @@ describe('TokenBridge Tests', () => { expect(xferTx.chain).toEqual(chain); const { transaction } = xferTx; - expect(transaction.instructions).toHaveLength(2); + expect(transaction.transaction.instructions).toHaveLength(2); }); }); }); diff --git a/platforms/solana/protocols/core/src/core.ts b/platforms/solana/protocols/core/src/core.ts index 50109ec65..822239d53 100644 --- a/platforms/solana/protocols/core/src/core.ts +++ b/platforms/solana/protocols/core/src/core.ts @@ -31,8 +31,6 @@ import { } from '@wormhole-foundation/connect-sdk'; import { Wormhole as WormholeCoreContract } from './types'; import { - BridgeData, - createBridgeFeeTransferInstruction, createPostMessageInstruction, createPostVaaInstruction, createReadOnlyWormholeProgramInterface, diff --git a/platforms/solana/protocols/tokenBridge/src/tokenBridge.ts b/platforms/solana/protocols/tokenBridge/src/tokenBridge.ts index 87c0cb906..82e90b02e 100644 --- a/platforms/solana/protocols/tokenBridge/src/tokenBridge.ts +++ b/platforms/solana/protocols/tokenBridge/src/tokenBridge.ts @@ -218,7 +218,7 @@ export class SolanaTokenBridge const msgFee = await this.coreBridge.getMessageFee(); const transferIx = coreUtils.createBridgeFeeTransferInstruction( - this.coreBridge.address, + this.coreBridge.coreBridge.programId, senderAddress, msgFee, ); From 2853f7773ae80a58598855deda78d38c15e96d76 Mon Sep 17 00:00:00 2001 From: Ben Guidarelli Date: Wed, 3 Jan 2024 14:56:47 -0500 Subject: [PATCH 5/5] dont override the commitment level --- examples/src/helpers/helpers.ts | 4 +-- platforms/solana/src/platform.ts | 6 ++-- platforms/solana/src/testing/sendSigner.ts | 40 +++++++++++++++++++--- platforms/solana/src/testing/signer.ts | 5 +-- 4 files changed, 40 insertions(+), 15 deletions(-) diff --git a/examples/src/helpers/helpers.ts b/examples/src/helpers/helpers.ts index 133e5e9ba..81d7ed7a8 100644 --- a/examples/src/helpers/helpers.ts +++ b/examples/src/helpers/helpers.ts @@ -18,7 +18,7 @@ import { import { getAlgorandSigner } from "@wormhole-foundation/connect-sdk-algorand/src/testing"; import { getCosmwasmSigner } from "@wormhole-foundation/connect-sdk-cosmwasm/src/testing"; import { getEvmSigner } from "@wormhole-foundation/connect-sdk-evm/src/testing"; -import { getSolanaSigner } from "@wormhole-foundation/connect-sdk-solana/src/testing"; +import { getSolanaSignAndSendSigner } from "@wormhole-foundation/connect-sdk-solana/src/testing"; // Use .env.example as a template for your .env file and populate it with secrets // for funded accounts on the relevant chain+network combos to run the example @@ -56,7 +56,7 @@ export async function getStuff< const platform = chain.platform.utils()._platform; switch (platform) { case "Solana": - signer = await getSolanaSigner(await chain.getRpc(), getEnv("SOL_PRIVATE_KEY")); + signer = await getSolanaSignAndSendSigner(await chain.getRpc(), getEnv("SOL_PRIVATE_KEY")); break; case "Cosmwasm": signer = await getCosmwasmSigner(await chain.getRpc(), getEnv("COSMOS_MNEMONIC")); diff --git a/platforms/solana/src/platform.ts b/platforms/solana/src/platform.ts index 1d28eab4a..e950ca7ce 100644 --- a/platforms/solana/src/platform.ts +++ b/platforms/solana/src/platform.ts @@ -209,13 +209,11 @@ export class SolanaPlatform extends PlatformContext< rpc: Connection, commitment?: Commitment, ): Promise<{ blockhash: string; lastValidBlockHeight: number }> { - // Use finalized to prevent blockhash not found errors - // Note: this may mean we have less time to submit transactions? - return rpc.getLatestBlockhash(commitment ?? 'finalized'); + return rpc.getLatestBlockhash(commitment ?? rpc.commitment); } static async getLatestBlock(rpc: Connection): Promise { - const { lastValidBlockHeight } = await this.latestBlock(rpc, 'confirmed'); + const { lastValidBlockHeight } = await this.latestBlock(rpc); return lastValidBlockHeight; } diff --git a/platforms/solana/src/testing/sendSigner.ts b/platforms/solana/src/testing/sendSigner.ts index bbcd9c45a..19188e9c6 100644 --- a/platforms/solana/src/testing/sendSigner.ts +++ b/platforms/solana/src/testing/sendSigner.ts @@ -1,4 +1,5 @@ import { + ComputeBudgetProgram, Connection, Keypair, SendOptions, @@ -15,6 +16,9 @@ import { SolanaChains } from '../types'; import { SolanaUnsignedTransaction } from '../unsignedTransaction'; import { logTxDetails } from './debug'; +// Number of blocks to wait before considering a transaction expired +const SOLANA_EXPIRED_BLOCKHEIGHT = 150; + export class SolanaSendSigner< N extends Network, C extends SolanaChains = 'Solana', @@ -26,6 +30,7 @@ export class SolanaSendSigner< private _keypair: Keypair, private _debug: boolean = false, private _sendOpts?: SendOptions, + private _priotifyFeeAmount?: bigint, ) { this._sendOpts = this._sendOpts ?? { preflightCommitment: this._rpc.commitment, @@ -42,6 +47,7 @@ export class SolanaSendSigner< // Handles retrying a Transaction if the error is deemed to be // recoverable. Currently handles: + // - Transaction expired // - Blockhash not found // - Not enough bytes (storage account not seen yet) private retryable(e: any): boolean { @@ -75,7 +81,6 @@ export class SolanaSendSigner< async signAndSend(tx: UnsignedTransaction[]): Promise { let { blockhash, lastValidBlockHeight } = await SolanaPlatform.latestBlock( this._rpc, - 'finalized', ); const txids: string[] = []; @@ -86,10 +91,18 @@ export class SolanaSendSigner< } = txn as SolanaUnsignedTransaction; console.log(`Signing: ${description} for ${this.address()}`); + if (this._priotifyFeeAmount) + transaction.add( + ComputeBudgetProgram.setComputeUnitPrice({ + microLamports: this._priotifyFeeAmount, + }), + ); + if (this._debug) logTxDetails(transaction); // Try to send the transaction up to 5 times - for (let i = 0; i < 5; i++) { + const maxRetries = 5; + for (let i = 0; i < maxRetries; i++) { try { transaction.recentBlockhash = blockhash; transaction.partialSign(this._keypair, ...(extraSigners ?? [])); @@ -101,11 +114,28 @@ export class SolanaSendSigner< txids.push(txid); break; } catch (e) { + // No point checking if retryable if we're on the last retry + if (i === maxRetries - 1) throw e; + + // If it's not retryable, throw if (!this.retryable(e)) throw e; - // If it is retryable, we should grab a new block hash - ({ blockhash, lastValidBlockHeight } = - await SolanaPlatform.latestBlock(this._rpc, 'finalized')); + // If it is retryable, we need to grab a new block hash + const { + blockhash: newBlockhash, + lastValidBlockHeight: newBlockHeight, + } = await SolanaPlatform.latestBlock(this._rpc); + + // But we should _not_ submit if the blockhash hasnt expired + if ( + newBlockHeight - lastValidBlockHeight < + SOLANA_EXPIRED_BLOCKHEIGHT + ) { + throw e; + } + + lastValidBlockHeight = newBlockHeight; + blockhash = newBlockhash; } } } diff --git a/platforms/solana/src/testing/signer.ts b/platforms/solana/src/testing/signer.ts index fcc15fd26..ddca70694 100644 --- a/platforms/solana/src/testing/signer.ts +++ b/platforms/solana/src/testing/signer.ts @@ -27,10 +27,7 @@ export class SolanaSigner } async sign(tx: UnsignedTransaction[]): Promise { - const { blockhash } = await SolanaPlatform.latestBlock( - this._rpc, - 'finalized', - ); + const { blockhash } = await SolanaPlatform.latestBlock(this._rpc); const signed = []; for (const txn of tx) {