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

Merged
merged 21 commits into from
Jun 14, 2024
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
8307a74
add auto restore functionality for contract client
BlaineHeffron May 28, 2024
56ccdd8
Update test/unit/server/soroban/assembled_transaction_test.js
BlaineHeffron May 29, 2024
5ba241a
Update test/unit/server/soroban/assembled_transaction_test.js
BlaineHeffron May 29, 2024
133051a
Update src/contract/assembled_transaction.ts
BlaineHeffron May 29, 2024
f5092b2
extract getAccount to utils
BlaineHeffron May 29, 2024
2dcfbaa
TSDoc format
BlaineHeffron May 29, 2024
986ee7c
make account optional for restoreFootprint
BlaineHeffron May 29, 2024
26a2648
remove bald booleans
BlaineHeffron May 29, 2024
ad02f9f
cleanup, remove updateTimeout workaround, dont rebuild sorobandata
BlaineHeffron Jun 5, 2024
2a2ce15
add changelog entry for auto restore functionality
BlaineHeffron Jun 5, 2024
0d096d1
remove comment
BlaineHeffron Jun 5, 2024
08ab269
remove unused var
BlaineHeffron Jun 5, 2024
e5db079
simpler wording
BlaineHeffron Jun 5, 2024
1dccd9b
fixed position of changelog entry
BlaineHeffron Jun 5, 2024
87ca94f
add space after `if` in src/contract/assembled_transaction.ts
chadoh Jun 13, 2024
319d577
add space after `if` in src/contract/assembled_transaction.ts
chadoh Jun 13, 2024
d009c0e
switch order of isSimulationRestore and `restore` check
chadoh Jun 13, 2024
ead51f5
add stub result when simulation returns blank
chadoh Jun 13, 2024
2654c10
add note about restoreTransaction arg
chadoh Jun 13, 2024
c96e8c6
fix missing check from merge with toXDR / fromXDR feature
BlaineHeffron Jun 14, 2024
1c761ef
fix empty scVal construction
BlaineHeffron Jun 14, 2024
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
9 changes: 3 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,9 @@ A breaking change will get clearly marked in this log.
## Unreleased

### Added
- `contract.AssembledTransaction` now has a `toXDR` and `fromXDR` method 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.
- `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.

### Deprecated
- In `contract.AssembledTransaction`, `toJSON` and `fromJSON` should be replaced with `toXDR` and
Expand Down
167 changes: 133 additions & 34 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";
import { Spec } from "./spec";
Expand Down Expand Up @@ -308,6 +310,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 Down Expand Up @@ -437,9 +440,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 @@ -453,27 +457,74 @@ export class AssembledTransaction<T> {
return tx;
}

simulate = async (): Promise<this> => {
if (!this.built) {
if (!this.raw) {
throw new Error(
"Transaction has not yet been assembled; " +
"call `AssembledTransaction.build` first.",
);
}
private static async buildFootprintRestoreTransaction<T>(
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;
}

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

restore = restore ?? this.options.restore;
this.built = this.raw.build();

// need to force re-calculation of simulationData for new simulation
delete this.simulationResult;
delete this.simulationTransactionData;
this.simulation = await this.server.simulateTransaction(this.built);

if (restore && Api.isSimulationRestore(this.simulation)) {
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
);
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();
}

Expand Down Expand Up @@ -502,26 +553,14 @@ 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.'
);
}

// add to object for serialization & deserialization
this.simulationResult = simulation.result;
this.simulationResult = simulation.result ?? { auth: [], retval: new xdr.ScVal() };
this.simulationTransactionData = simulation.transactionData.build();

return {
Expand All @@ -532,6 +571,9 @@ export class AssembledTransaction<T> {

get result(): T {
try {
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;
Expand Down Expand Up @@ -578,26 +620,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 @@ -789,4 +834,58 @@ export class AssembledTransaction<T> {
.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(
/**
* 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. If omitted, will use the account from the AssembledTransaction. */
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>,
chadoh marked this conversation as resolved.
Show resolved Hide resolved
server: Server
): Promise<Account> {
return options.publicKey
? await server.getAccount(options.publicKey)
: new Account(NULL_ACCOUNT, "0");
}
Loading
Loading