diff --git a/CHANGELOG.md b/CHANGELOG.md index e2642090a..04e0d5df0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/contract/assembled_transaction.ts b/src/contract/assembled_transaction.ts index e4d627c0f..6003de128 100644 --- a/src/contract/assembled_transaction.ts +++ b/src/contract/assembled_transaction.ts @@ -5,6 +5,7 @@ import { BASE_FEE, Contract, Operation, + SorobanDataBuilder, StrKey, TransactionBuilder, authorizeEntry, @@ -26,6 +27,7 @@ import { DEFAULT_TIMEOUT, contractErrorPattern, implementsToString, + getAccount } from "./utils"; import { SentTransaction } from "./sent_transaction"; import { Spec } from "./spec"; @@ -308,6 +310,7 @@ export class AssembledTransaction { */ 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 { }, @@ -437,9 +440,10 @@ export class AssembledTransaction { 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, @@ -453,27 +457,75 @@ export class AssembledTransaction { return tx; } - simulate = async (): Promise => { - if (!this.built) { - if (!this.raw) { + private static async buildFootprintRestoreTransaction( + options: AssembledTransactionOptions, + sorobanData: SorobanDataBuilder | xdr.SorobanTransactionData, + account: Account, + fee: string + ): Promise> { + 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 => { + if (!this.built){ + if(!this.raw) { throw new Error( "Transaction has not yet been assembled; " + - "call `AssembledTransaction.build` first.", + "call `AssembledTransaction.build` first." ); } - this.built = this.raw.build(); } - this.simulation = await this.server.simulateTransaction(this.built); + restore = restore ?? this.options.restore; // 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(); } @@ -502,26 +554,14 @@ export class AssembledTransaction { 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: xdr.ScVal.scvVoid() }; this.simulationTransactionData = simulation.transactionData.build(); return { @@ -532,6 +572,9 @@ export class AssembledTransaction { 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; @@ -578,26 +621,29 @@ export class AssembledTransaction { 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 = this; - const sent = await SentTransaction.init(signTransaction, typeChecked); + const sent = await SentTransaction.init( + signTransaction, + typeChecked, + ); return sent; }; @@ -789,4 +835,58 @@ export class AssembledTransaction { .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 { + 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; + } + + } diff --git a/src/contract/sent_transaction.ts b/src/contract/sent_transaction.ts index d6f275d78..8d033e648 100644 --- a/src/contract/sent_transaction.ts +++ b/src/contract/sent_transaction.ts @@ -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" @@ -87,10 +87,8 @@ export class SentTransaction { 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(); diff --git a/src/contract/types.ts b/src/contract/types.ts index 8b92276c2..d329bd0da 100644 --- a/src/contract/types.ts +++ b/src/contract/types.ts @@ -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 = MethodOptions & diff --git a/src/contract/utils.ts b/src/contract/utils.ts index 44e7b8b02..db99d7cbb 100644 --- a/src/contract/utils.ts +++ b/src/contract/utils.ts @@ -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. @@ -107,3 +110,12 @@ export function processSpecEntryStream(buffer: Buffer) { } return res; } + +export async function getAccount( + options: AssembledTransactionOptions, + server: Server +): Promise { + return options.publicKey + ? await server.getAccount(options.publicKey) + : new Account(NULL_ACCOUNT, "0"); +} diff --git a/test/unit/server/soroban/assembled_transaction_test.js b/test/unit/server/soroban/assembled_transaction_test.js new file mode 100644 index 000000000..6b433fae6 --- /dev/null +++ b/test/unit/server/soroban/assembled_transaction_test.js @@ -0,0 +1,120 @@ +const { + Account, + Keypair, + Networks, + rpc, + SorobanDataBuilder, + xdr, + contract, +} = StellarSdk; +const { Server, AxiosClient, parseRawSimulation } = StellarSdk.rpc; + +const restoreTxnData = StellarSdk.SorobanDataBuilder.fromXDR("AAAAAAAAAAAAAAAEAAAABgAAAAHZ4Y4l0GNoS97QH0fa5Jbbm61Ou3t9McQ09l7wREKJYwAAAA8AAAAJUEVSU19DTlQxAAAAAAAAAQAAAAYAAAAB2eGOJdBjaEve0B9H2uSW25utTrt7fTHENPZe8ERCiWMAAAAPAAAACVBFUlNfQ05UMgAAAAAAAAEAAAAGAAAAAdnhjiXQY2hL3tAfR9rkltubrU67e30xxDT2XvBEQoljAAAAFAAAAAEAAAAH+BoQswzzGTKRzrdC6axxKaM4qnyDP8wgQv8Id3S4pbsAAAAAAAAGNAAABjQAAAAAAADNoQ=="); + +describe("AssembledTransaction.buildFootprintRestoreTransaction", () => { + const keypair = Keypair.random(); + const contractId = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM"; + const networkPassphrase = "Standalone Network ; February 2017"; + const wallet = contract.basicNodeSigner(keypair, networkPassphrase); + const options = { + networkPassphrase, + contractId, + rpcUrl: serverUrl, + allowHttp: true, + publicKey: keypair.publicKey(), + ...wallet, + } + + beforeEach(function () { + this.server = new Server(serverUrl); + this.axiosMock = sinon.mock(AxiosClient); + }); + + afterEach(function () { + this.axiosMock.verify(); + this.axiosMock.restore(); + }); + + + + it("makes expected RPC calls", function (done) { + const simulateTransactionResponse = { + transactionData: restoreTxnData, + minResourceFee: "52641", + cost: { cpuInsns: "0", memBytes: "0" }, + latestLedger: 17027, + }; + + const sendTransactionResponse = { + "status": "PENDING", + "hash": "05870e35fc94e5424f72d125959760b5f60631d91452bde2d11126fb5044e35d", + "latestLedger": 17034, + "latestLedgerCloseTime": "1716483573" + }; + const getTransactionResponse = { + status: "SUCCESS", + latestLedger: 17037, + latestLedgerCloseTime: "1716483576", + oldestLedger: 15598, + oldestLedgerCloseTime: "1716482133", + applicationOrder: 1, + envelopeXdr: "AAAAAgAAAAARwpJYOq4lKj/RdtS7ds3ciGSMfZUp+7d4xgg9vsN7qQABm0IAAAvWAAAAAwAAAAEAAAAAAAAAAAAAAABmT3cbAAAAAAAAAAEAAAAAAAAAGgAAAAAAAAABAAAAAAAAAAAAAAAEAAAABgAAAAHZ4Y4l0GNoS97QH0fa5Jbbm61Ou3t9McQ09l7wREKJYwAAAA8AAAAJUEVSU19DTlQxAAAAAAAAAQAAAAYAAAAB2eGOJdBjaEve0B9H2uSW25utTrt7fTHENPZe8ERCiWMAAAAPAAAACVBFUlNfQ05UMgAAAAAAAAEAAAAGAAAAAdnhjiXQY2hL3tAfR9rkltubrU67e30xxDT2XvBEQoljAAAAFAAAAAEAAAAH+BoQswzzGTKRzrdC6axxKaM4qnyDP8wgQv8Id3S4pbsAAAAAAAAGNAAABjQAAAAAAADNoQAAAAG+w3upAAAAQGBfsx+gyi/2Dh6i+7Vbb6Ongw3HDcFDZ48eoadkUUvkq97zdPe3wYGFswZgT5/GXPqGDBi+iqHuZiYx5eSy3Qk=", + resultXdr: "AAAAAAAAiRkAAAAAAAAAAQAAAAAAAAAaAAAAAAAAAAA=", + resultMetaXdr: "AAAAAwAAAAAAAAACAAAAAwAAQowAAAAAAAAAABHCklg6riUqP9F21Lt2zdyIZIx9lSn7t3jGCD2+w3upAAAAF0h1Pp0AAAvWAAAAAgAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAMAAAAAAAAMMQAAAABmTz9yAAAAAAAAAAEAAEKMAAAAAAAAAAARwpJYOq4lKj/RdtS7ds3ciGSMfZUp+7d4xgg9vsN7qQAAABdIdT6dAAAL1gAAAAMAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAADAAAAAAAAQowAAAAAZk919wAAAAAAAAABAAAACAAAAAMAAAwrAAAACc4pIDe7y0sRFHAghrdpB7ypfj4BVuZStvX4u0BC1S/YAAANVgAAAAAAAAABAABCjAAAAAnOKSA3u8tLERRwIIa3aQe8qX4+AVbmUrb1+LtAQtUv2AAAQ7cAAAAAAAAAAwAADCsAAAAJikpmJa7Pr3lTb+dhRP2N4TOYCqK4tL4tQhDYnNEijtgAAA1WAAAAAAAAAAEAAEKMAAAACYpKZiWuz695U2/nYUT9jeEzmAqiuLS+LUIQ2JzRIo7YAABDtwAAAAAAAAADAAAMMQAAAAlT7LdEin/CaQA3iscHqkwnEFlSh8jfTPTIhSQ5J8Ao0wAADVwAAAAAAAAAAQAAQowAAAAJU+y3RIp/wmkAN4rHB6pMJxBZUofI30z0yIUkOSfAKNMAAEO3AAAAAAAAAAMAAAwxAAAACQycyCYjh7j9CHnTm9OKCYXhgmXw6jdtoMsGHyPk8Aa+AAANXAAAAAAAAAABAABCjAAAAAkMnMgmI4e4/Qh505vTigmF4YJl8Oo3baDLBh8j5PAGvgAAQ7cAAAAAAAAAAgAAAAMAAEKMAAAAAAAAAAARwpJYOq4lKj/RdtS7ds3ciGSMfZUp+7d4xgg9vsN7qQAAABdIdT6dAAAL1gAAAAMAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAADAAAAAAAAQowAAAAAZk919wAAAAAAAAABAABCjAAAAAAAAAAAEcKSWDquJSo/0XbUu3bN3IhkjH2VKfu3eMYIPb7De6kAAAAXSHWDiQAAC9YAAAADAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAwAAAAAAAEKMAAAAAGZPdfcAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAA", + ledger: 17036, + createdAt: "1716483575", + }; + this.axiosMock + .expects("post") + .withArgs( + serverUrl, + sinon.match({ + jsonrpc: "2.0", + id: 1, + method: "simulateTransaction", + }) + ) + .returns(Promise.resolve({ data: { result: simulateTransactionResponse } })); + this.axiosMock + .expects("post") + .withArgs(serverUrl, + sinon.match({ + jsonrpc: "2.0", + id: 1, + method: "getTransaction", + }) + ) + .returns(Promise.resolve({ data: { result: getTransactionResponse } })); + + this.axiosMock + .expects("post") + .withArgs(serverUrl, + sinon.match({ + jsonrpc: "2.0", + id: 1, + method: "sendTransaction", + }) + ) + .returns(Promise.resolve({ data: { result: sendTransactionResponse } })); + + contract.AssembledTransaction.buildFootprintRestoreTransaction( + options, + restoreTxnData, + new StellarSdk.Account( + "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI", + "1", + ), + 52641, + ) + .then((txn) => txn.signAndSend({ ...wallet })) + .then((result) => { + expect(result.getTransactionResponse.status).to.equal(rpc.Api.GetTransactionStatus.SUCCESS); + done(); + }) + .catch((error) => { + // handle any errors that occurred during the promise chain + done(error); + }); + + }) +});