Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: separate sign and send #987

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
75 changes: 66 additions & 9 deletions src/contract/assembled_transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,11 @@ export class AssembledTransaction<T> {
*/
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
Expand Down Expand Up @@ -504,13 +509,10 @@ export class AssembledTransaction<T> {
}

/**
* 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,
}: {
Expand All @@ -522,7 +524,7 @@ export class AssembledTransaction<T> {
* You must provide this here if you did not provide one before
*/
signTransaction?: ClientOptions["signTransaction"];
} = {}): Promise<SentTransaction<T>> => {
} = {}): Promise<void> => {
if (!this.built) {
throw new Error("Transaction has not yet been simulated");
}
Expand All @@ -548,9 +550,64 @@ export class AssembledTransaction<T> {
);
}

const typeChecked: AssembledTransaction<T> = 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<SentTransaction<T>> => {
if(!this.signed){
await this.sign({ force, signTransaction });
}
return this.send();
};

private getStorageExpiration = async () => {
Expand Down
73 changes: 24 additions & 49 deletions src/contract/sent_transaction.ts
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -24,8 +22,6 @@ import type { AssembledTransaction } from "./assembled_transaction";
export class SentTransaction<T> {
public server: Server;

public signed?: Tx;

/**
* The result of calling `sendTransaction` to broadcast the transaction to the
* network.
Expand Down Expand Up @@ -53,61 +49,38 @@ export class SentTransaction<T> {
};

constructor(
public signTransaction: ClientOptions["signTransaction"],
public assembled: AssembledTransaction<T>,
public options: SentTransactionOptions<T>,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh wait this is a breaking change, right? ⛔

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, good call, we need to add that to the breaking changes section of the changelog

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any way we can avoid it? It's too late for breaking changes since we already tagged off a non-RC build.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, let me revert that change, it's going to make for some silly code but that's better than a breaking change

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 <U>(
/** More info in {@link MethodOptions} */
signTransaction: ClientOptions["signTransaction"],
/** {@link AssembledTransaction} from which this SentTransaction was initialized */
assembled: AssembledTransaction<U>,
/**
* 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.
Comment on lines +68 to +72
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reminder: these don't show up anywhere!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hopefully they show up in the generated html docs??

*/
options: SentTransactionOptions<U>,
/** The signed transaction to send to the network */
signed: Tx,
): Promise<SentTransaction<U>> => {
const tx = new SentTransaction(signTransaction, assembled);
const tx = new SentTransaction(options, signed);
const sent = await tx.send();
return sent;
};

private send = async (): Promise<this> => {
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,
);
Expand All @@ -124,6 +97,8 @@ export class SentTransaction<T> {

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,
Expand Down Expand Up @@ -160,7 +135,7 @@ export class SentTransaction<T> {
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!,
);
}
Expand All @@ -185,7 +160,7 @@ export class SentTransaction<T> {

// 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)}`,
);
}
}
7 changes: 7 additions & 0 deletions src/contract/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,10 @@ export type AssembledTransactionOptions<T = string> = MethodOptions &
args?: any[];
parseResultXdr: (xdr: xdr.ScVal) => T;
};

export type SentTransactionOptions<T> = {
timeoutInSeconds?: number,
rpcUrl: string,
allowHttp?: boolean,
parseResultXdr: (xdr: xdr.ScVal) => T,
};
Loading