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

feature: add auto restore functionality for contract client #974

Closed
wants to merge 14 commits into from
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ A breaking change will get clearly marked in this log.

## Unreleased

### Added
- `contract.AssembledTransaction` now has 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.


## [v12.0.1](https://github.com/stellar/js-stellar-sdk/compare/v11.3.0...v12.0.1)

Expand Down
161 changes: 131 additions & 30 deletions src/contract/assembled_transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
BASE_FEE,
Contract,
Operation,
SorobanDataBuilder,
StrKey,
TransactionBuilder,
authorizeEntry,
Expand All @@ -26,6 +27,7 @@ import {
DEFAULT_TIMEOUT,
contractErrorPattern,
implementsToString,
getAccount
} from "./utils";
import { SentTransaction } from "./sent_transaction";

Expand Down Expand Up @@ -307,6 +309,7 @@ export class AssembledTransaction<T> {
*/
static Errors = {
ExpiredState: class ExpiredStateError extends Error { },
RestorationFailure: class RestoreFailureError extends Error { },
NeedsMoreSignatures: class NeedsMoreSignaturesError extends Error { },
NoSignatureNeeded: class NoSignatureNeededError extends Error { },
NoUnsignedNonInvokerAuthEntries: class NoUnsignedNonInvokerAuthEntriesError extends Error { },
Expand All @@ -326,8 +329,8 @@ export class AssembledTransaction<T> {
method: this.options.method,
tx: this.built?.toXDR(),
simulationResult: {
auth: this.simulationData.result.auth.map((a) => a.toXDR("base64")),
retval: this.simulationData.result.retval.toXDR("base64"),
auth: this.simulationData.result?.auth.map((a) => a.toXDR("base64")),
retval: this.simulationData.result?.retval.toXDR("base64"),
},
simulationTransactionData:
this.simulationData.transactionData.toXDR("base64"),
Expand Down Expand Up @@ -395,9 +398,10 @@ export class AssembledTransaction<T> {
const tx = new AssembledTransaction(options);
const contract = new Contract(options.contractId);

const account = options.publicKey
? await tx.server.getAccount(options.publicKey)
: new Account(NULL_ACCOUNT, "0");
const account = await getAccount(
options,
tx.server
);

tx.raw = new TransactionBuilder(account, {
fee: options.fee ?? BASE_FEE,
Expand All @@ -411,29 +415,78 @@ export class AssembledTransaction<T> {
return tx;
}

simulate = async (): Promise<this> => {
private static async buildFootprintRestoreTransaction<T>(
BlaineHeffron marked this conversation as resolved.
Show resolved Hide resolved
options: AssembledTransactionOptions<T>,
sorobanData: SorobanDataBuilder | xdr.SorobanTransactionData,
account: Account,
fee: string
): Promise<AssembledTransaction<T>> {
const tx = new AssembledTransaction(options);
tx.raw = new TransactionBuilder(account, {
fee,
networkPassphrase: options.networkPassphrase,
})
.setSorobanData(sorobanData instanceof SorobanDataBuilder ? sorobanData.build() : sorobanData)
.addOperation(Operation.restoreFootprint({}))
.setTimeout(options.timeoutInSeconds ?? DEFAULT_TIMEOUT);
await tx.simulate({ restore: false });
return tx;
}

simulate = async ({ restore }: {restore?: boolean} = {}): Promise<this> => {
if (!this.raw) {
throw new Error(
"Transaction has not yet been assembled; " +
"call `AssembledTransaction.build` first.",
"call `AssembledTransaction.build` first."
);
}

restore = restore ?? this.options.restore;
this.built = this.raw.build();
this.simulation = await this.server.simulateTransaction(this.built);

if (Api.isSimulationRestore(this.simulation) && restore) {
const account = await getAccount(this.options, this.server);
const result = await this.restoreFootprint(
this.simulation.restorePreamble,
account
);
if (result.status === Api.GetTransactionStatus.SUCCESS) {
// need to rebuild the transaction with bumped account sequence number
const contract = new Contract(this.options.contractId);
this.raw = new TransactionBuilder(account, {
fee: this.options.fee ?? BASE_FEE,
networkPassphrase: this.options.networkPassphrase,
})
.addOperation(
contract.call(
this.options.method,
...(this.options.args ?? [])
)
)
.setTimeout(
this.options.timeoutInSeconds ?? DEFAULT_TIMEOUT
);
BlaineHeffron marked this conversation as resolved.
Show resolved Hide resolved
await this.simulate();
return this;
}
throw new AssembledTransaction.Errors.RestorationFailure(
`Automatic restore failed! You set 'restore: true' but the attempted restore did not work. Result:\n${JSON.stringify(result)}`
);
}

if (Api.isSimulationSuccess(this.simulation)) {
this.built = assembleTransaction(
this.built,
this.simulation,
this.simulation
).build();
}

return this;
};

get simulationData(): {
result: Api.SimulateHostFunctionResult;
result?: Api.SimulateHostFunctionResult;
transactionData: xdr.SorobanTransactionData;
} {
if (this.simulationResult && this.simulationTransactionData) {
Expand All @@ -454,21 +507,9 @@ export class AssembledTransaction<T> {

if (Api.isSimulationRestore(simulation)) {
throw new AssembledTransaction.Errors.ExpiredState(
`You need to restore some contract state before you can invoke this method. ${JSON.stringify(
simulation,
null,
2,
)}`,
);
}

if (!simulation.result) {
throw new Error(
`Expected an invocation simulation, but got no 'result' field. Simulation: ${JSON.stringify(
simulation,
null,
2,
)}`,
`You need to restore some contract state before you can invoke this method.\n` +
'You can set `restore` to true in the method options in order to ' +
'automatically restore the contract state when needed.'
);
}

Expand All @@ -484,7 +525,10 @@ export class AssembledTransaction<T> {

get result(): T {
try {
return this.options.parseResultXdr(this.simulationData.result.retval);
if(!this.simulationData.result){
throw new Error("No simulation result!");
}
return this.options.parseResultXdr(this.simulationData.result?.retval);
} catch (e) {
if (!implementsToString(e)) throw e;
const err = this.parseError(e.toString());
Expand Down Expand Up @@ -530,26 +574,29 @@ export class AssembledTransaction<T> {
if (!force && this.isReadCall) {
throw new AssembledTransaction.Errors.NoSignatureNeeded(
"This is a read call. It requires no signature or sending. " +
"Use `force: true` to sign and send anyway.",
"Use `force: true` to sign and send anyway."
);
}

if (!signTransaction) {
throw new AssembledTransaction.Errors.NoSigner(
"You must provide a signTransaction function, either when calling " +
"`signAndSend` or when initializing your Client",
"`signAndSend` or when initializing your Client"
);
}

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 sent = await SentTransaction.init(
signTransaction,
typeChecked,
);
return sent;
};

Expand Down Expand Up @@ -734,11 +781,65 @@ export class AssembledTransaction<T> {
* returns `false`, then you need to call `signAndSend` on this transaction.
*/
get isReadCall(): boolean {
const authsCount = this.simulationData.result.auth.length;
const authsCount = this.simulationData.result?.auth.length;
const writeLength = this.simulationData.transactionData
.resources()
.footprint()
.readWrite().length;
return authsCount === 0 && writeLength === 0;
}

/**
* Restores the footprint (resource ledger entries that can be read or written)
* of an expired transaction.
*
* The method will:
* 1. Build a new transaction aimed at restoring the necessary resources.
* 2. Sign this new transaction if a `signTransaction` handler is provided.
* 3. Send the signed transaction to the network.
* 4. Await and return the response from the network.
*
* Preconditions:
* - A `signTransaction` function must be provided during the Client initialization.
* - The provided `restorePreamble` should include a minimum resource fee and valid
* transaction data.
*
* @throws {Error} - Throws an error if no `signTransaction` function is provided during
* Client initialization.
* @throws {AssembledTransaction.Errors.RestoreFailure} - Throws a custom error if the
* restore transaction fails, providing the details of the failure.
*/
async restoreFootprint(
BlaineHeffron marked this conversation as resolved.
Show resolved Hide resolved
/**
* The preamble object containing data required to
* build the restore transaction.
*/
restorePreamble: {
minResourceFee: string;
transactionData: SorobanDataBuilder;
},
/** The account that is executing the footprint restore operation. */
account?: Account
): Promise<Api.GetTransactionResponse> {
if(!this.options.signTransaction){
throw new Error("For automatic restore to work you must provide a signTransaction function when initializing your Client");
}
account = account ?? await getAccount(this.options, this.server);
// first try restoring the contract
const restoreTx = await AssembledTransaction.buildFootprintRestoreTransaction(
{ ...this.options },
restorePreamble.transactionData,
account,
restorePreamble.minResourceFee
);
const sentTransaction = await restoreTx.signAndSend();
if (!sentTransaction.getTransactionResponse) {
throw new AssembledTransaction.Errors.RestorationFailure(
`The attempt at automatic restore failed. \n${JSON.stringify(sentTransaction)}`
);
}
return sentTransaction.getTransactionResponse;
}


}
8 changes: 3 additions & 5 deletions src/contract/sent_transaction.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* disable max-classes rule, because extending error shouldn't count! */
/* eslint max-classes-per-file: 0 */
import { SorobanDataBuilder, TransactionBuilder } from "@stellar/stellar-base";
import { TransactionBuilder } from "@stellar/stellar-base";
import type { ClientOptions, MethodOptions, Tx } from "./types";
import { Server } from "../rpc/server"
import { Api } from "../rpc/api"
Expand Down Expand Up @@ -87,10 +87,8 @@ export class SentTransaction<T> {
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(),
timebounds: undefined, // intentionally don't clone timebounds
sorobanData: this.assembled.simulationData.transactionData
})
.setTimeout(timeoutInSeconds)
.build();
Expand Down
6 changes: 6 additions & 0 deletions src/contract/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,12 @@ export type MethodOptions = {
* AssembledTransaction. Default: true
*/
simulate?: boolean;

/**
* If true, will automatically attempt to restore the transaction if there
* are archived entries that need renewal. @default false
*/
restore?: boolean;
};

export type AssembledTransactionOptions<T = string> = MethodOptions &
Expand Down
16 changes: 14 additions & 2 deletions src/contract/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { xdr, cereal } from "@stellar/stellar-base";
import type { AssembledTransaction } from "./assembled_transaction";
import { xdr, cereal, Account } from "@stellar/stellar-base";
import { Server } from "../rpc/server";
import { NULL_ACCOUNT, type AssembledTransaction } from "./assembled_transaction";
import { AssembledTransactionOptions } from "./types";


/**
* The default timeout for waiting for a transaction to be included in a block.
Expand Down Expand Up @@ -107,3 +110,12 @@ export function processSpecEntryStream(buffer: Buffer) {
}
return res;
}

export async function getAccount<T>(
options: AssembledTransactionOptions<T>,
server: Server
): Promise<Account> {
return options.publicKey
? await server.getAccount(options.publicKey)
: new Account(NULL_ACCOUNT, "0");
}
Loading
Loading