diff --git a/CHANGELOG.md b/CHANGELOG.md index a3508b009..3e23d863f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ A breaking change will get clearly marked in this log. ## Unreleased +### Added +- `contract.AssembledTransaction` now supports separate `sign` and `send` methods so that you can sign a transaction without sending it. + ## [v12.0.1](https://github.com/stellar/js-stellar-sdk/compare/v11.3.0...v12.0.1) diff --git a/src/contract/assembled_transaction.ts b/src/contract/assembled_transaction.ts index 1f46824b9..20f5a914f 100644 --- a/src/contract/assembled_transaction.ts +++ b/src/contract/assembled_transaction.ts @@ -300,6 +300,11 @@ export class AssembledTransaction { */ private server: Server; + /** + * The signed transaction. + */ + private signed?: Tx; + /** * A list of the most important errors that various AssembledTransaction * methods can throw. Feel free to catch specific errors in your application @@ -504,13 +509,10 @@ export class AssembledTransaction { } /** - * Sign the transaction with the `wallet`, included previously. If you did - * not previously include one, you need to include one now that at least - * includes the `signTransaction` method. After signing, this method will - * send the transaction to the network and return a `SentTransaction` that - * keeps track of all the attempts to fetch the transaction. + * Sign the transaction with the signTransaction function included previously. + * If you did not previously include one, you need to include one now. */ - signAndSend = async ({ + sign = async ({ force = false, signTransaction = this.options.signTransaction, }: { @@ -522,7 +524,7 @@ export class AssembledTransaction { * You must provide this here if you did not provide one before */ signTransaction?: ClientOptions["signTransaction"]; - } = {}): Promise> => { + } = {}): Promise => { if (!this.built) { throw new Error("Transaction has not yet been simulated"); } @@ -548,9 +550,64 @@ export class AssembledTransaction { ); } - const typeChecked: AssembledTransaction = this; - const sent = await SentTransaction.init(signTransaction, typeChecked); + const timeoutInSeconds = + this.options.timeoutInSeconds ?? DEFAULT_TIMEOUT; + this.built = TransactionBuilder.cloneFrom(this.built!, { + fee: this.built!.fee, + timebounds: undefined, + sorobanData: this.simulationData.transactionData, + }) + .setTimeout(timeoutInSeconds) + .build(); + + const signature = await signTransaction( + this.built.toXDR(), + { + networkPassphrase: this.options.networkPassphrase, + }, + ); + + this.signed = TransactionBuilder.fromXDR( + signature, + this.options.networkPassphrase, + ) as Tx; + }; + + /** + * Sends the transaction to the network to return a `SentTransaction` that + * keeps track of all the attempts to fetch the transaction. + */ + async send(){ + if(!this.signed){ + throw new Error("The transaction has not yet been signed. Run `sign` first, or use `signAndSend` instead."); + } + const sent = await SentTransaction.init(this.options, this.signed); return sent; + } + + /** + * Sign the transaction with the `signTransaction` function included previously. + * If you did not previously include one, you need to include one now. + * After signing, this method will send the transaction to the network and + * return a `SentTransaction` that keeps track * of all the attempts to fetch the transaction. + */ + signAndSend = async ({ + force = false, + signTransaction = this.options.signTransaction, + }: { + /** + * If `true`, sign and send the transaction even if it is a read call + */ + force?: boolean; + /** + * You must provide this here if you did not provide one before + */ + signTransaction?: ClientOptions["signTransaction"]; + } = {}): Promise> => { + if(!this.signed){ + await this.sign({ force, signTransaction }); + } + return this.send(); }; private getStorageExpiration = async () => { diff --git a/src/contract/sent_transaction.ts b/src/contract/sent_transaction.ts index d6f275d78..98bbb56b6 100644 --- a/src/contract/sent_transaction.ts +++ b/src/contract/sent_transaction.ts @@ -1,11 +1,9 @@ /* disable max-classes rule, because extending error shouldn't count! */ /* eslint max-classes-per-file: 0 */ -import { SorobanDataBuilder, TransactionBuilder } from "@stellar/stellar-base"; -import type { ClientOptions, MethodOptions, Tx } from "./types"; +import type { MethodOptions, SentTransactionOptions, Tx } from "./types"; import { Server } from "../rpc/server" import { Api } from "../rpc/api" import { DEFAULT_TIMEOUT, withExponentialBackoff } from "./utils"; -import type { AssembledTransaction } from "./assembled_transaction"; /** * A transaction that has been sent to the Soroban network. This happens in two steps: @@ -24,8 +22,6 @@ import type { AssembledTransaction } from "./assembled_transaction"; export class SentTransaction { public server: Server; - public signed?: Tx; - /** * The result of calling `sendTransaction` to broadcast the transaction to the * network. @@ -53,61 +49,38 @@ export class SentTransaction { }; constructor( - public signTransaction: ClientOptions["signTransaction"], - public assembled: AssembledTransaction, + public options: SentTransactionOptions, + public signed: Tx, ) { - if (!signTransaction) { - throw new Error( - "You must provide a `signTransaction` function to send a transaction", - ); - } - this.server = new Server(this.assembled.options.rpcUrl, { - allowHttp: this.assembled.options.allowHttp ?? false, + this.server = new Server(this.options.rpcUrl, { + allowHttp: this.options.allowHttp ?? false, }); } /** - * Initialize a `SentTransaction` from an existing `AssembledTransaction` and - * a `signTransaction` function. This will also send the transaction to the - * network. + * Initialize a `SentTransaction` from `options` and a `signed` + * AssembledTransaction. This will also send the transaction to the network. */ static init = async ( - /** More info in {@link MethodOptions} */ - signTransaction: ClientOptions["signTransaction"], - /** {@link AssembledTransaction} from which this SentTransaction was initialized */ - assembled: AssembledTransaction, + /** + * Configuration options for initializing the SentTransaction. + * + * @typedef {Object} SentTransactionOptions + * @property {number} [timeoutInSeconds] - Optional timeout duration in seconds for the transaction. + * @property {string} rpcUrl - The RPC URL of the network to which the transaction will be sent. + * @property {boolean} [allowHttp] - Optional flag to allow HTTP connections (default is false, HTTPS is preferred). + * @property {(xdr: xdr.ScVal) => U} parseResultXdr - Function to parse the XDR result returned by the network. + */ + options: SentTransactionOptions, + /** The signed transaction to send to the network */ + signed: Tx, ): Promise> => { - const tx = new SentTransaction(signTransaction, assembled); + const tx = new SentTransaction(options, signed); const sent = await tx.send(); return sent; }; private send = async (): Promise => { - const timeoutInSeconds = - this.assembled.options.timeoutInSeconds ?? DEFAULT_TIMEOUT; - this.assembled.built = TransactionBuilder.cloneFrom(this.assembled.built!, { - fee: this.assembled.built!.fee, - timebounds: undefined, - sorobanData: new SorobanDataBuilder( - this.assembled.simulationData.transactionData.toXDR(), - ).build(), - }) - .setTimeout(timeoutInSeconds) - .build(); - - const signature = await this.signTransaction!( - // `signAndSend` checks for `this.built` before calling `SentTransaction.init` - this.assembled.built!.toXDR(), - { - networkPassphrase: this.assembled.options.networkPassphrase, - }, - ); - - this.signed = TransactionBuilder.fromXDR( - signature, - this.assembled.options.networkPassphrase, - ) as Tx; - this.sendTransactionResponse = await this.server.sendTransaction( this.signed, ); @@ -124,6 +97,8 @@ export class SentTransaction { const { hash } = this.sendTransactionResponse; + const timeoutInSeconds = + this.options.timeoutInSeconds ?? DEFAULT_TIMEOUT; this.getTransactionResponseAll = await withExponentialBackoff( () => this.server.getTransaction(hash), (resp) => resp.status === Api.GetTransactionStatus.NOT_FOUND, @@ -160,7 +135,7 @@ export class SentTransaction { if ("getTransactionResponse" in this && this.getTransactionResponse) { // getTransactionResponse has a `returnValue` field unless it failed if ("returnValue" in this.getTransactionResponse) { - return this.assembled.options.parseResultXdr( + return this.options.parseResultXdr( this.getTransactionResponse.returnValue!, ); } @@ -185,7 +160,7 @@ export class SentTransaction { // 3. finally, if neither of those are present, throw an error throw new Error( - `Sending transaction failed: ${JSON.stringify(this.assembled)}`, + `Sending transaction failed: ${JSON.stringify(this.signed)}`, ); } } diff --git a/src/contract/types.ts b/src/contract/types.ts index 8b92276c2..461880d1b 100644 --- a/src/contract/types.ts +++ b/src/contract/types.ts @@ -117,3 +117,10 @@ export type AssembledTransactionOptions = MethodOptions & args?: any[]; parseResultXdr: (xdr: xdr.ScVal) => T; }; + +export type SentTransactionOptions = { + timeoutInSeconds?: number, + rpcUrl: string, + allowHttp?: boolean, + parseResultXdr: (xdr: xdr.ScVal) => T, +};