diff --git a/src/contract_client/assembled_transaction.ts b/src/contract_client/assembled_transaction.ts index 5a57dc544..9ee798a68 100644 --- a/src/contract_client/assembled_transaction.ts +++ b/src/contract_client/assembled_transaction.ts @@ -14,17 +14,207 @@ import { Transaction, TransactionBuilder, authorizeEntry, - hash, xdr, } from ".."; import { Err } from "../rust_types"; const DEFAULT_TIMEOUT = 10; +/** + * Shortcut to SorobanRpc's Transaction type, constructed in the "regular + * transaction" way (as opposed to FeeBumpTransactions). + */ type Tx = Transaction, Operation[]>; -type SendTx = SorobanRpc.Api.SendTransactionResponse; -type GetTx = SorobanRpc.Api.GetTransactionResponse; +/** + * The main workhorse of {@link ContractClient}. This class is used to wrap a + * transaction-under-construction and provide high-level interfaces to the most + * common workflows, while still providing access to low-level stellar-sdk + * transaction manipulation. + * + * Most of the time, you will not construct an `AssembledTransaction` directly, + * but instead receive one as the return value of a `ContractClient` method. If + * you're familiar with the libraries generated by soroban-cli's `contract + * bindings typescript` command, these also wraps `ContractClient` and return + * `AssembledTransaction` instances. + * + * Let's look at examples of how to use `AssembledTransaction` for a variety of + * use-cases: + * + * # 1. Simple read call + * + * Since these only require simulation, you can get the `result` of the call + * right after constructing your `AssembledTransaction`: + * + * ```ts + * const { result } = await AssembledTransaction.build({ + * method: 'myReadMethod', + * args: spec.funcArgsToScVals('myReadMethod', { args: 'for', my: 'method', ... }), + * contractId: 'C123…', + * networkPassphrase: '…', + * rpcUrl: 'https://…', + * publicKey: Keypair.random().publicKey(), // keypairs are irrelevant, for simulation-only read calls + * parseResultXdr: (result: xdr.ScVal) => spec.funcResToNative('myReadMethod', result), + * }) + * ``` + * + * While that looks pretty complicated, most of the time you will use this in + * conjunction with {@link ContractClient}, which simplifies it to: + * + * ```ts + * const { result } = await client.myReadMethod({ args: 'for', my: 'method', ... }) + * ``` + * + * # 2. Simple write call + * + * For write calls that will be simulated and then sent to the network without + * further manipulation, only one more step is needed: + * + * ```ts + * const assembledTx = await client.myWriteMethod({ args: 'for', my: 'method', ... }) + * const sentTx = await assembledTx.signAndSend() + * ``` + * + * Here we're assuming that you're using a {@link ContractClient}, rather than + * constructing `AssembledTransaction`'s directly. + * + * Note that `sentTx`, the return value of `signAndSend`, is a + * {@link SentTransaction}. `SentTransaction` is similar to + * `AssembledTransaction`, but is missing many of the methods and fields that + * are only relevant while assembling a transaction. It also has a few extra + * methods and fields that are only relevant after the transaction has been + * sent to the network. + * + * Like `AssembledTransaction`, `SentTransaction` also has a `result` getter, + * which contains the parsed final return value of the contract call. Most of + * the time, you may only be interested in this, so rather than getting the + * whole `sentTx` you may just want to: + * + * ```ts + * const tx = await client.myWriteMethod({ args: 'for', my: 'method', ... }) + * const { result } = await tx.signAndSend() + * ``` + * + * # 3. More fine-grained control over transaction construction + * + * If you need more control over the transaction before simulating it, you can + * set various {@link MethodOptions} when constructing your + * `AssembledTransaction`. With a {@link ContractClient}, this is passed as a + * second object after the arguments (or the only object, if the method takes + * no arguments): + * + * ```ts + * const tx = await client.myWriteMethod( + * { + * args: 'for', + * my: 'method', + * ... + * }, { + * fee: '10000', // default: {@link BASE_FEE} + * simulate: false, + * timeoutInSeconds: 20, // default: {@link DEFAULT_TIMEOUT} + * } + * ) + * ``` + * + * Since we've skipped simulation, we can now edit the `raw` transaction and + * then manually call `simulate`: + * + * ```ts + * tx.raw.addMemo(Memo.text('Nice memo, friend!')) + * await tx.simulate() + * ``` + * + * If you need to inspect the simulation later, you can access it with `tx.simulation`. + * + * # 4. Multi-auth workflows + * + * Soroban, and Stellar in general, allows multiple parties to sign a + * transaction. + * + * Let's consider an Atomic Swap contract. Alice wants to give 10 of her Token + * A tokens to Bob for 5 of his Token B tokens. + * + * ```ts + * const ALICE = 'G123...' + * const BOB = 'G456...' + * const TOKEN_A = 'C123…' + * const TOKEN_B = 'C456…' + * const AMOUNT_A = 10n + * const AMOUNT_B = 5n + * ``` + * + * Let's say Alice is also going to be the one signing the final transaction + * envelope, meaning she is the invoker. So your app, from Alice's browser, + * simulates the `swap` call: + * + * ```ts + * const tx = await swapClient.swap({ + * a: ALICE, + * b: BOB, + * token_a: TOKEN_A, + * token_b: TOKEN_B, + * amount_a: AMOUNT_A, + * amount_b: AMOUNT_B, + * }) + * ``` + * + * But your app can't `signAndSend` this right away, because Bob needs to sign + * it first. You can check this: + * + * ```ts + * const whoElseNeedsToSign = tx.needsNonInvokerSigningBy() + * ``` + * + * You can verify that `whoElseNeedsToSign` is an array of length `1`, + * containing only Bob's public key. + * + * Then, still on Alice's machine, you can serialize the + * transaction-under-assembly: + * + * ```ts + * const json = tx.toJSON() + * ``` + * + * And now you need to send it to Bob's browser. How you do this depends on + * your app. Maybe you send it to a server first, maybe you use WebSockets, or + * maybe you have Alice text the JSON blob to Bob and have him paste it into + * your app in his browser (note: this option might be error-prone 😄). + * + * Once you get the JSON blob into your app on Bob's machine, you can + * deserialize it: + * + * ```ts + * const tx = swapClient.txFromJSON(json) + * ``` + * + * Or, if you're using a client generated with `soroban contract bindings + * typescript`, this deserialization will look like: + * + * ```ts + * const tx = swapClient.fromJSON.swap(json) + * ``` + * + * Then you can have Bob sign it. What Bob will actually need to sign is some + * _auth entries_ within the transaction, not the transaction itself or the + * transaction envelope. Your app can verify that Bob has the correct wallet + * selected, then: + * + * ```ts + * await tx.signAuthEntries() + * ``` + * + * Under the hood, this uses `signAuthEntry`, which you either need to inject + * during initial construction of the `ContractClient`/`AssembledTransaction`, + * or which you can pass directly to `signAuthEntries`. + * + * Now Bob can again serialize the transaction and send back to Alice, where + * she can finally call `signAndSend()`. + * + * To see an even more complicated example, where Alice swaps with Bob but the + * transaction is invoked by yet another party, check out + * [test-swap.js](../../test/e2e/src/test-swap.js). + */ export class AssembledTransaction { /** * The TransactionBuilder as constructed in `{@link @@ -57,14 +247,24 @@ export class AssembledTransaction { public simulation?: SorobanRpc.Api.SimulateTransactionResponse; /** * Cached simulation result. This is set after the first call to - * `simulationData`, and is used to facilitate serialization and + * {@link 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`. */ private simulationResult?: SorobanRpc.Api.SimulateHostFunctionResult; /** * Cached simulation transaction data. This is set after the first call to - * `simulationData`, and is used to facilitate serialization and + * {@link 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`. */ private simulationTransactionData?: xdr.SorobanTransactionData; /** @@ -144,6 +344,24 @@ export class AssembledTransaction { }); } + /** + * Construct a new AssembledTransaction. This is the only way to create a new + * AssembledTransaction; the main 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. + * + * const tx = await AssembledTransaction.build({ + * ..., + * simulate: false, + * }) + */ static async build( options: AssembledTransactionOptions ): Promise> { @@ -153,7 +371,7 @@ export class AssembledTransaction { const account = await tx.server.getAccount(options.publicKey); tx.raw = new TransactionBuilder(account, { - fee: options.fee?.toString(10) ?? BASE_FEE, + fee: options.fee ?? BASE_FEE, networkPassphrase: options.networkPassphrase, }) .addOperation(contract.call(options.method, ...(options.args ?? []))) @@ -244,7 +462,7 @@ export class AssembledTransaction { } } - parseError(errorMessage: string) { + private parseError(errorMessage: string) { if (!this.options.errorTypes) return undefined; const match = errorMessage.match(contractErrorPattern); if (!match) return undefined; @@ -303,7 +521,7 @@ export class AssembledTransaction { return await SentTransaction.init(signTransaction, typeChecked); }; - getStorageExpiration = async () => { + private getStorageExpiration = async () => { const entryRes = await this.server.getLedgerEntries( new Contract(this.options.contractId).getFootprint() ); @@ -380,21 +598,6 @@ export class AssembledTransaction { ]; }; - preImageFor( - entry: xdr.SorobanAuthorizationEntry, - signatureExpirationLedger: number - ): xdr.HashIdPreimage { - const addrAuth = entry.credentials().address(); - return xdr.HashIdPreimage.envelopeTypeSorobanAuthorization( - new xdr.HashIdPreimageSorobanAuthorization({ - networkId: hash(Buffer.from(this.options.networkPassphrase)), - nonce: addrAuth.nonce(), - invocation: entry.rootInvocation(), - signatureExpirationLedger, - }) - ); - } - /** * If {@link needsNonInvokerSigningBy} returns a non-empty list, you can serialize * the transaction with `toJSON`, send it to the owner of one of the public keys @@ -421,7 +624,7 @@ export class AssembledTransaction { * contract's current `persistent` storage expiration date/ledger * number/block. */ - expiration?: number | Promise + expiration?: number | Promise /** * Sign all auth entries for this account. Default: the account that * constructed the transaction @@ -518,9 +721,9 @@ export class AssembledTransaction { class SentTransaction { public server: SorobanRpc.Server; public signed?: Tx; - public sendTransactionResponse?: SendTx; - public getTransactionResponse?: GetTx; - public getTransactionResponseAll?: GetTx[]; + public sendTransactionResponse?: SorobanRpc.Api.SendTransactionResponse; + public getTransactionResponse?: SorobanRpc.Api.GetTransactionResponse; + public getTransactionResponseAll?: SorobanRpc.Api.GetTransactionResponse[]; static Errors = { SendFailed: class SendFailedError extends Error {}, diff --git a/src/contract_client/types.ts b/src/contract_client/types.ts index 8b1e8485e..deeb5d8e6 100644 --- a/src/contract_client/types.ts +++ b/src/contract_client/types.ts @@ -86,7 +86,7 @@ export type MethodOptions = { /** * The fee to pay for the transaction. Default: {@link BASE_FEE} */ - fee?: number; + fee?: string; /** * The maximum amount of time to wait for the transaction to complete. Default: {@link DEFAULT_TIMEOUT}