UNPKG

@stellar/stellar-sdk

Version:

A library for working with the Stellar network, including communication with the Horizon and Soroban RPC servers.

734 lines (730 loc) 28.5 kB
'use strict'; var buffer = require('buffer'); require('../node_modules/.pnpm/@stellar_js-xdr@4.0.0/node_modules/@stellar/js-xdr/src/int.js'); require('../node_modules/.pnpm/@stellar_js-xdr@4.0.0/node_modules/@stellar/js-xdr/src/hyper.js'); require('../node_modules/.pnpm/@stellar_js-xdr@4.0.0/node_modules/@stellar/js-xdr/src/unsigned-int.js'); require('../node_modules/.pnpm/@stellar_js-xdr@4.0.0/node_modules/@stellar/js-xdr/src/unsigned-hyper.js'); require('../node_modules/.pnpm/@stellar_js-xdr@4.0.0/node_modules/@stellar/js-xdr/src/xdr-type.js'); var curr_generated = require('../base/generated/curr_generated.js'); require('@noble/hashes/sha2.js'); require('../base/signing.js'); require('../base/keypair.js'); require('base32.js'); var operation = require('../base/operation.js'); require('../base/util/bignumber.js'); var transaction_builder = require('../base/transaction_builder.js'); require('../base/muxed_account.js'); var contract = require('../base/contract.js'); var address = require('../base/address.js'); require('../base/scval.js'); var sorobandata_builder = require('../base/sorobandata_builder.js'); var auth = require('../base/auth.js'); require('../base/numbers/uint128.js'); require('../base/numbers/uint256.js'); require('../base/numbers/int128.js'); require('../base/numbers/int256.js'); var signer = require('./signer.js'); var api = require('../rpc/api.js'); var server = require('../rpc/server.js'); var transaction = require('../rpc/transaction.js'); var rust_result = require('./rust_result.js'); var utils = require('./utils.js'); var types = require('./types.js'); var sent_transaction = require('./sent_transaction.js'); var errors = require('./errors.js'); class AssembledTransaction { constructor(options) { this.options = options; this.options.simulate = this.options.simulate ?? true; const { server: server$1, allowHttp, headers, rpcUrl } = this.options; this.server = server$1 ?? new server.RpcServer(rpcUrl, { allowHttp, headers }); } options; /** * The TransactionBuilder as constructed in * {@link AssembledTransaction}.build. Feel free set `simulate: false` to modify * this object before calling `tx.simulate()` manually. Example: * * ```ts * const tx = await myContract.myMethod( * { args: 'for', my: 'method', ... }, * { simulate: false } * ); * tx.raw.addMemo(Memo.text('Nice memo, friend!')) * await tx.simulate(); * ``` */ raw; /** * Stores the original operation from `buildWithOp` for reuse during * automatic state restoration rebuilds. */ originalOp; /** * The Transaction as it was built with `raw.build()` right before * simulation. Once this is set, modifying `raw` will have no effect unless * you call `tx.simulate()` again. */ built; /** * The result of the transaction simulation. This is set after the first call * to `simulate`. It is difficult to serialize and deserialize, so it is not * included in the `toJSON` and `fromJSON` methods. See `simulationData` * cached, serializable access to the data needed by AssembledTransaction * logic. */ simulation; /** * Cached simulation result. This is set after the first call to * {@link AssembledTransaction.simulationData}, and is used to facilitate * serialization and deserialization of the AssembledTransaction. * * Most of the time, if you need this data, you can call * `tx.simulation.result`. * * If you need access to this data after a transaction has been serialized * and then deserialized, you can call `simulationData.result`. */ simulationResult; /** * Cached simulation transaction data. This is set after the first call to * {@link AssembledTransaction.simulationData}, and is used to facilitate * serialization and deserialization of the AssembledTransaction. * * Most of the time, if you need this data, you can call * `simulation.transactionData`. * * If you need access to this data after a transaction has been serialized * and then deserialized, you can call `simulationData.transactionData`. */ simulationTransactionData; /** * The Soroban server to use for all RPC calls. This is constructed from the * `rpcUrl` in the options. */ server; /** * The signed transaction. */ signed; /** * A list of the most important errors that various AssembledTransaction * methods can throw. Feel free to catch specific errors in your application * logic. */ static Errors = { ExpiredState: errors.ExpiredStateError, RestorationFailure: errors.RestoreFailureError, NeedsMoreSignatures: errors.NeedsMoreSignaturesError, NoSignatureNeeded: errors.NoSignatureNeededError, NoUnsignedNonInvokerAuthEntries: errors.NoUnsignedNonInvokerAuthEntriesError, NoSigner: errors.NoSignerError, NotYetSimulated: errors.NotYetSimulatedError, FakeAccount: errors.FakeAccountError, SimulationFailed: errors.SimulationFailedError, InternalWalletError: errors.InternalWalletError, ExternalServiceError: errors.ExternalServiceError, InvalidClientRequest: errors.InvalidClientRequestError, UserRejected: errors.UserRejectedError }; /** * Serialize the AssembledTransaction to a JSON string. This is useful for * saving the transaction to a database or sending it over the wire for * multi-auth workflows. `fromJSON` can be used to deserialize the * transaction. This only works with transactions that have been simulated. */ toJSON() { return JSON.stringify({ 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") }, simulationTransactionData: this.simulationData.transactionData.toXDR("base64") }); } /** * Validate that a built transaction is a single invokeContract operation * targeting the expected contract, and return the parsed InvokeContractArgs. */ static validateInvokeContractOp(built, expectedContractId) { if (built.operations.length !== 1) { throw new Error( "Transaction envelope must contain exactly one operation." ); } const operation = built.operations[0]; if (operation.type !== "invokeHostFunction") { throw new Error( "Transaction envelope does not contain an invokeHostFunction operation." ); } const invokeOp = operation; if (invokeOp.func.switch().name !== "hostFunctionTypeInvokeContract") { throw new Error( "Transaction envelope does not contain an invokeContract host function." ); } const invokeContractArgs = invokeOp.func.value(); let contractAddress; let functionName; try { contractAddress = invokeContractArgs.contractAddress(); functionName = invokeContractArgs.functionName().toString("utf-8"); } catch { throw new Error( "Could not extract contract address or method name from the transaction envelope." ); } if (!contractAddress || !functionName) { throw new Error( "Could not extract contract address or method name from the transaction envelope." ); } const xdrContractId = address.Address.fromScAddress(contractAddress).toString(); if (xdrContractId !== expectedContractId) { throw new Error( `Transaction envelope targets contract ${xdrContractId}, but this Client is configured for ${expectedContractId}.` ); } return invokeContractArgs; } static fromJSON(options, { tx, simulationResult, simulationTransactionData }) { const txn = new AssembledTransaction(options); txn.built = transaction_builder.TransactionBuilder.fromXDR(tx, options.networkPassphrase); const invokeContractArgs = AssembledTransaction.validateInvokeContractOp( txn.built, options.contractId ); const xdrMethod = invokeContractArgs.functionName().toString("utf-8"); if (xdrMethod !== options.method) { throw new Error( `Transaction envelope calls method '${xdrMethod}', but the provided method is '${options.method}'.` ); } txn.simulationResult = { auth: simulationResult.auth.map( (a) => curr_generated.default.SorobanAuthorizationEntry.fromXDR(a, "base64") ), retval: curr_generated.default.ScVal.fromXDR(simulationResult.retval, "base64") }; txn.simulationTransactionData = curr_generated.default.SorobanTransactionData.fromXDR( simulationTransactionData, "base64" ); return txn; } /** * Serialize the AssembledTransaction to a base64-encoded XDR string. */ toXDR() { if (!this.built) throw new Error( "Transaction has not yet been simulated; call `AssembledTransaction.simulate` first." ); return this.built?.toEnvelope().toXDR("base64"); } /** * Deserialize the AssembledTransaction from a base64-encoded XDR string. */ static fromXDR(options, encodedXDR, spec) { const envelope = curr_generated.default.TransactionEnvelope.fromXDR(encodedXDR, "base64"); const built = transaction_builder.TransactionBuilder.fromXDR( envelope, options.networkPassphrase ); const invokeContractArgs = AssembledTransaction.validateInvokeContractOp( built, options.contractId ); const method = invokeContractArgs.functionName().toString("utf-8"); const txn = new AssembledTransaction({ ...options, method, parseResultXdr: (result) => spec.funcResToNative(method, result) }); txn.built = built; return txn; } handleWalletError(error) { if (!error) return; const { message, code } = error; const fullMessage = `${message}${error.ext ? ` (${error.ext.join(", ")})` : ""}`; switch (code) { case -1: throw new AssembledTransaction.Errors.InternalWalletError(fullMessage); case -2: throw new AssembledTransaction.Errors.ExternalServiceError(fullMessage); case -3: throw new AssembledTransaction.Errors.InvalidClientRequest(fullMessage); case -4: throw new AssembledTransaction.Errors.UserRejected(fullMessage); default: throw new Error(`Unhandled error: ${fullMessage}`); } } /** * Construct a new AssembledTransaction. This is the main way to create a new * AssembledTransaction; the constructor is private. * * This is an asynchronous constructor for two reasons: * * 1. It needs to fetch the account from the network to get the current * sequence number. * 2. It needs to simulate the transaction to get the expected fee. * * If you don't want to simulate the transaction, you can set `simulate` to * `false` in the options. * * If you need to create an operation other than `invokeHostFunction`, you * can use {@link AssembledTransaction.buildWithOp} instead. * * @example * ```ts * const tx = await AssembledTransaction.build({ * ..., * simulate: false, * }) * ``` */ static build(options) { const contract$1 = new contract.Contract(options.contractId); return AssembledTransaction.buildWithOp( contract$1.call(options.method, ...options.args ?? []), options ); } /** * Construct a new AssembledTransaction, specifying an Operation other than * `invokeHostFunction` (the default used by {@link AssembledTransaction.build}). * * Note: `AssembledTransaction` currently assumes these operations can be * simulated. This is not true for classic operations; only for those used by * Soroban Smart Contracts like `invokeHostFunction` and `createCustomContract`. * * @example * ```ts * const tx = await AssembledTransaction.buildWithOp( * Operation.createCustomContract({ ... }); * { * ..., * simulate: false, * } * ) * ``` */ static async buildWithOp(operation, options) { const tx = new AssembledTransaction(options); tx.originalOp = operation; const account = await utils.getAccount(options, tx.server); tx.raw = new transaction_builder.TransactionBuilder(account, { fee: options.fee ?? transaction_builder.BASE_FEE, networkPassphrase: options.networkPassphrase }).setTimeout(options.timeoutInSeconds ?? types.DEFAULT_TIMEOUT).addOperation(operation); if (options.simulate) await tx.simulate(); return tx; } static async buildFootprintRestoreTransaction(options, sorobanData, account, fee) { const tx = new AssembledTransaction(options); tx.raw = new transaction_builder.TransactionBuilder(account, { fee, networkPassphrase: options.networkPassphrase }).setSorobanData( sorobanData instanceof sorobandata_builder.SorobanDataBuilder ? sorobanData.build() : sorobanData ).addOperation(operation.Operation.restoreFootprint({})).setTimeout(options.timeoutInSeconds ?? types.DEFAULT_TIMEOUT); await tx.simulate({ restore: false }); return tx; } simulate = async ({ restore, useUpgradedAuth } = {}) => { if (!this.built) { if (!this.raw) { throw new Error( "Transaction has not yet been assembled; call `AssembledTransaction.build` first." ); } this.built = this.raw.build(); } restore = restore ?? this.options.restore; useUpgradedAuth = useUpgradedAuth ?? this.options.useUpgradedAuth; delete this.simulationResult; delete this.simulationTransactionData; this.simulation = await this.server.simulateTransaction( this.built, void 0, void 0, useUpgradedAuth ); if (restore && api.Api.isSimulationRestore(this.simulation)) { const account = await utils.getAccount(this.options, this.server); const result = await this.restoreFootprint( this.simulation.restorePreamble, account ); if (result.status === api.Api.GetTransactionStatus.SUCCESS) { const op = this.originalOp ? this.originalOp : new contract.Contract(this.options.contractId).call( this.options.method, ...this.options.args ?? [] ); this.raw = new transaction_builder.TransactionBuilder(account, { fee: this.options.fee ?? transaction_builder.BASE_FEE, networkPassphrase: this.options.networkPassphrase }).addOperation(op).setTimeout(this.options.timeoutInSeconds ?? types.DEFAULT_TIMEOUT); delete this.built; await this.simulate({ useUpgradedAuth }); return this; } throw new AssembledTransaction.Errors.RestorationFailure( `Automatic restore failed! You set 'restore: true' but the attempted restore did not work. Result: ${JSON.stringify(result)}` ); } if (api.Api.isSimulationSuccess(this.simulation)) { this.built = transaction.assembleTransaction(this.built, this.simulation).build(); } return this; }; get simulationData() { if (this.simulationResult && this.simulationTransactionData) { return { result: this.simulationResult, transactionData: this.simulationTransactionData }; } const simulation = this.simulation; if (!simulation) { throw new AssembledTransaction.Errors.NotYetSimulated( "Transaction has not yet been simulated" ); } if (api.Api.isSimulationError(simulation)) { throw new AssembledTransaction.Errors.SimulationFailed( `Transaction simulation failed: "${simulation.error}"` ); } if (api.Api.isSimulationRestore(simulation)) { throw new AssembledTransaction.Errors.ExpiredState( `You need to restore some contract state before you can invoke this method. You can set \`restore\` to true in the method options in order to automatically restore the contract state when needed.` ); } this.simulationResult = simulation.result ?? { auth: [], retval: curr_generated.default.ScVal.scvVoid() }; this.simulationTransactionData = simulation.transactionData.build(); return { result: this.simulationResult, transactionData: this.simulationTransactionData }; } get result() { try { if (!this.simulationData.result) { throw new Error("No simulation result!"); } return this.options.parseResultXdr(this.simulationData.result.retval); } catch (e) { if (!utils.implementsToString(e)) throw e; const err = this.parseError(e.toString()); if (err) return err; throw e; } } parseError(errorMessage) { if (!this.options.errorTypes) return void 0; const match = errorMessage.match(utils.contractErrorPattern); if (!match) return void 0; const i = parseInt(match[1], 10); const err = this.options.errorTypes[i]; if (!err) return void 0; return new rust_result.Err(err); } /** * Sign the transaction with the signTransaction function included previously. * If you did not previously include one, you need to include one now. */ sign = async ({ force = false, signTransaction = this.options.signTransaction } = {}) => { if (!this.built) { throw new Error("Transaction has not yet been simulated"); } 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." ); } const signTx = signer.toSignTransaction( signTransaction, this.options.networkPassphrase ); if (!signTx) { throw new AssembledTransaction.Errors.NoSigner( "You must provide a signTransaction function, either when calling `signAndSend` or when initializing your Client" ); } if (!this.options.publicKey) { throw new AssembledTransaction.Errors.FakeAccount( "This transaction was constructed using a default account. Provide a valid publicKey in the AssembledTransactionOptions." ); } const sigsNeeded = this.needsNonInvokerSigningBy().filter( (id) => !id.startsWith("C") ); if (sigsNeeded.length) { throw new AssembledTransaction.Errors.NeedsMoreSignatures( `Transaction requires signatures from ${sigsNeeded}. See \`needsNonInvokerSigningBy\` for details.` ); } const timeoutInSeconds = this.options.timeoutInSeconds ?? types.DEFAULT_TIMEOUT; this.built = transaction_builder.TransactionBuilder.cloneFrom(this.built, { fee: this.built.fee, timebounds: void 0, sorobanData: this.simulationData.transactionData }).setTimeout(timeoutInSeconds).build(); const signOpts = { networkPassphrase: this.options.networkPassphrase }; if (this.options.address) signOpts.address = this.options.address; if (this.options.submit !== void 0) signOpts.submit = this.options.submit; if (this.options.submitUrl) signOpts.submitUrl = this.options.submitUrl; const { signedTxXdr: signature, error } = await signTx( this.built.toXDR(), signOpts ); this.handleWalletError(error); this.signed = transaction_builder.TransactionBuilder.fromXDR( signature, this.options.networkPassphrase ); }; /** * Sends the transaction to the network to return a `SentTransaction` that * keeps track of all the attempts to fetch the transaction. Optionally pass * a {@link Watcher} that allows you to keep track of the progress as the * transaction is sent and processed. */ async send(watcher) { if (!this.signed) { throw new Error( "The transaction has not yet been signed. Run `sign` first, or use `signAndSend` instead." ); } const sent = await sent_transaction.SentTransaction.init(this, watcher); 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. You may pass a {@link Watcher} to keep * track of this progress. */ signAndSend = async ({ force = false, signTransaction = this.options.signTransaction, watcher } = {}) => { if (!this.signed) { const signer$1 = signer.toSignTransaction( signTransaction || this.options.signTransaction, this.options.networkPassphrase ); const wrappedSignTransaction = this.options.submit && signer$1 ? (tx, opts) => signer$1(tx, { ...opts, submit: false }) : signTransaction; await this.sign({ force, signTransaction: wrappedSignTransaction }); } return this.send(watcher); }; /** * Get a list of accounts, other than the invoker of the simulation, that * need to sign auth entries in this transaction. * * Soroban allows multiple people to sign a transaction. Someone needs to * sign the final transaction envelope; this person/account is called the * _invoker_, or _source_. Other accounts might need to sign individual auth * entries in the transaction, if they're not also the invoker. * * This function returns a list of accounts that need to sign auth entries, * assuming that the same invoker/source account will sign the final * transaction envelope as signed the initial simulation. * * One at a time, for each public key in this array, you will need to * serialize this transaction with `toJSON`, send to the owner of that key, * deserialize the transaction with `txFromJson`, and call * {@link AssembledTransaction.signAuthEntries}. Then re-serialize and send to * the next account in this list. */ needsNonInvokerSigningBy = ({ includeAlreadySigned = false } = {}) => { if (!this.built) { throw new Error("Transaction has not yet been simulated"); } if (!("operations" in this.built)) { throw new Error( `Unexpected Transaction type; no operations: ${JSON.stringify( this.built )}` ); } const rawInvokeHostFunctionOp = this.built.operations[0]; return [ ...new Set( (rawInvokeHostFunctionOp.auth ?? []).map((entry) => auth.inspectAuthEntry(entry)).filter( (info) => ( // skip source-account credentials (no address payload), which // are covered by the envelope signature on the source account. // Only the top-level credentials (signers[0]) matter here — this // method reports (and signAuthEntries signs) the top-level // address, so unsigned delegate nodes must not keep it listed. info.address !== null && (includeAlreadySigned || !info.signers[0].signed) ) ).map((info) => info.address) ) ]; }; /** * If {@link AssembledTransaction.needsNonInvokerSigningBy} returns a * non-empty list, you can serialize the transaction with `toJSON`, send it to * the owner of one of the public keys in the map, deserialize with * `txFromJSON`, and call this method on their machine. Internally, this will * use `signAuthEntry` function from connected `wallet` for each. * * Then, re-serialize the transaction and either send to the next * `needsNonInvokerSigningBy` owner, or send it back to the original account * who simulated the transaction so they can {@link AssembledTransaction.sign} * the transaction envelope and {@link AssembledTransaction.send} it to the * network. * * Sending to all `needsNonInvokerSigningBy` owners in parallel is not * currently supported! */ signAuthEntries = async ({ expiration = (async () => (await this.server.getLatestLedger()).sequence + 100)(), signAuthEntry = this.options.signAuthEntry, address: address$1 = signer.signerAddress(signAuthEntry) ?? this.options.publicKey, authorizeEntry = auth.authorizeEntry } = {}) => { if (!this.built) throw new Error("Transaction has not yet been assembled or simulated"); const signAuth = signer.toSignAuthEntry( signAuthEntry, this.options.networkPassphrase ); if (authorizeEntry === auth.authorizeEntry) { const needsNonInvokerSigningBy = this.needsNonInvokerSigningBy(); if (needsNonInvokerSigningBy.length === 0) { throw new AssembledTransaction.Errors.NoUnsignedNonInvokerAuthEntries( "No unsigned non-invoker auth entries; maybe you already signed?" ); } if (needsNonInvokerSigningBy.indexOf(address$1 ?? "") === -1) { throw new AssembledTransaction.Errors.NoSignatureNeeded( `No auth entries for public key "${address$1}"` ); } if (!signAuth) { throw new AssembledTransaction.Errors.NoSigner( "You must provide `signAuthEntry` or a custom `authorizeEntry`" ); } } const rawInvokeHostFunctionOp = this.built.operations[0]; const authEntries = rawInvokeHostFunctionOp.auth ?? []; for (const [i, entry] of authEntries.entries()) { const credentials = curr_generated.default.SorobanCredentials.fromXDR( entry.credentials().toXDR() ); const addrAuth = auth.getAddressCredentials(credentials); if (addrAuth === null) { continue; } const authEntryAddress = address.Address.fromScAddress( addrAuth.address() ).toString(); if (authEntryAddress !== address$1) continue; const sign = signAuth ?? Promise.resolve; authEntries[i] = await authorizeEntry( entry, async (preimage) => { const { signedAuthEntry, error } = await sign( preimage.toXDR("base64"), { address: address$1 } ); this.handleWalletError(error); return buffer.Buffer.from(signedAuthEntry, "base64"); }, await expiration, this.options.networkPassphrase ); } }; /** * Whether this transaction is a read call. This is determined by the * simulation result and the transaction data. If the transaction is a read * call, it will not need to be signed and sent to the network. If this * returns `false`, then you need to call `signAndSend` on this transaction. */ get isReadCall() { 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 - Throws an error if no `signTransaction` function is provided during * Client initialization. * @throws - Throws a custom error if the * restore transaction fails, providing the details of the failure. */ async restoreFootprint(restorePreamble, account) { 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 utils.getAccount(this.options, this.server); 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. ${JSON.stringify(sentTransaction)}` ); } return sentTransaction.getTransactionResponse; } } exports.AssembledTransaction = AssembledTransaction; //# sourceMappingURL=assembled_transaction.js.map