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 #992

Merged
merged 14 commits into from
Jun 14, 2024
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ A breaking change will get clearly marked in this log.
- `contract.AssembledTransaction` now has:
- `toXDR` and `fromXDR` methods for serializing the transaction to and from XDR. Additionally, `contract.Client` now has a `txFromXDR`. These methods should be used in place of `AssembledTransaction.toJSON`, `AssembledTransaction.fromJSON`, and `Client.txFromJSON` for multi-auth signing. The JSON methods are now deprecated. **Note you must now call `simulate` on the transaction before the final `signAndSend` call after all required signatures are gathered when using the XDR methods.
- a `restoreFootprint` method which accepts the `restorePreamble` returned when a simulation call fails due to some contract state that has expired. When invoking a contract function, one can now set `restore` to `true` in the `MethodOptions`. When enabled, a `restoreFootprint` transaction will be created and await signing when required.
- separate `sign` and `send` methods so that you can sign a transaction without sending it. You can continue to use `signAndSend` if you prefer.

### Deprecated
- In `contract.AssembledTransaction`, `toJSON` and `fromJSON` should be replaced with `toXDR` and
Expand Down
78 changes: 66 additions & 12 deletions src/contract/assembled_transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,11 @@ export class AssembledTransaction<T> {
*/
private server: Server;

/**
* The signed transaction.
*/
public 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 @@ -595,13 +600,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 @@ -613,7 +615,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 @@ -635,16 +637,68 @@ export class AssembledTransaction<T> {
if (this.needsNonInvokerSigningBy().length) {
throw new AssembledTransaction.Errors.NeedsMoreSignatures(
"Transaction requires more signatures. " +
"See `needsNonInvokerSigningBy` for details."
"See `needsNonInvokerSigningBy` for details.",
);
}

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(undefined, this);
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
52 changes: 11 additions & 41 deletions src/contract/sent_transaction.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
/* disable max-classes rule, because extending error shouldn't count! */
/* eslint max-classes-per-file: 0 */
import { TransactionBuilder } from "@stellar/stellar-base";
import type { ClientOptions, MethodOptions, Tx } from "./types";
import type { MethodOptions } from "./types";
import { Server } from "../rpc/server"
import { Api } from "../rpc/api"
import { DEFAULT_TIMEOUT, withExponentialBackoff } from "./utils";
Expand All @@ -24,8 +23,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 +50,32 @@ export class SentTransaction<T> {
};

constructor(
public signTransaction: ClientOptions["signTransaction"],
_: any, // deprecated: used to take sentTransaction, need to wait for major release for breaking change
public assembled: AssembledTransaction<T>,
) {
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,
});
}

/**
* 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"],
/** @deprecated variable is ignored. Now handled by AssembledTransaction. */
_: any, // eslint-disable-line @typescript-eslint/no-unused-vars
/** {@link AssembledTransaction} from which this SentTransaction was initialized */
assembled: AssembledTransaction<U>,
): Promise<SentTransaction<U>> => {
const tx = new SentTransaction(signTransaction, assembled);
const tx = new SentTransaction(undefined, assembled);
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, // intentionally don't clone timebounds
sorobanData: this.assembled.simulationData.transactionData
})
.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,
this.assembled.signed!,
);

if (this.sendTransactionResponse.status !== "PENDING") {
Expand All @@ -122,6 +90,8 @@ export class SentTransaction<T> {

const { hash } = this.sendTransactionResponse;

const timeoutInSeconds =
this.assembled.options.timeoutInSeconds ?? DEFAULT_TIMEOUT;
this.getTransactionResponseAll = await withExponentialBackoff(
() => this.server.getTransaction(hash),
(resp) => resp.status === Api.GetTransactionStatus.NOT_FOUND,
Expand Down Expand Up @@ -183,7 +153,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.assembled.signed)}`,
);
}
}
Loading