Skip to content

Commit

Permalink
Add separate sign and send methods to `contract.AssembledTransact…
Browse files Browse the repository at this point in the history
…ion` (stellar#992)

* add auto restore functionality for contract client
* extract getAccount to utils
* add changelog entry for auto restore functionality

Co-authored-by: Chad Ostrowski <221614+chadoh@users.noreply.github.com>
Co-authored-by: blaineheffron <baheffron@gmail.com>
  • Loading branch information
chadoh and BlaineHeffron committed Jun 20, 2024
1 parent 6e9f9bb commit cae564f
Show file tree
Hide file tree
Showing 3 changed files with 78 additions and 53 deletions.
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)}`,
);
}
}

0 comments on commit cae564f

Please sign in to comment.