Skip to content

Commit

Permalink
docs(contract-client): clean api, write a book
Browse files Browse the repository at this point in the history
Yes, a whole book about AssembledTransaction. It needed documentation;
why not make it useful.

This also removes an obsolute method, marks another as private,
adds detail to other comments, and fixes the `fee` type.
  • Loading branch information
chadoh committed Mar 14, 2024
1 parent c778a50 commit e5c524e
Show file tree
Hide file tree
Showing 2 changed files with 231 additions and 28 deletions.
257 changes: 230 additions & 27 deletions src/contract_client/assembled_transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Memo<MemoType>, 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<T> {
/**
* The TransactionBuilder as constructed in `{@link
Expand Down Expand Up @@ -57,14 +247,24 @@ export class AssembledTransaction<T> {
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;
/**
Expand Down Expand Up @@ -144,6 +344,24 @@ export class AssembledTransaction<T> {
});
}

/**
* 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<T>(
options: AssembledTransactionOptions<T>
): Promise<AssembledTransaction<T>> {
Expand All @@ -153,7 +371,7 @@ export class AssembledTransaction<T> {
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 ?? [])))
Expand Down Expand Up @@ -244,7 +462,7 @@ export class AssembledTransaction<T> {
}
}

parseError(errorMessage: string) {
private parseError(errorMessage: string) {
if (!this.options.errorTypes) return undefined;
const match = errorMessage.match(contractErrorPattern);
if (!match) return undefined;
Expand Down Expand Up @@ -303,7 +521,7 @@ export class AssembledTransaction<T> {
return await SentTransaction.init(signTransaction, typeChecked);
};

getStorageExpiration = async () => {
private getStorageExpiration = async () => {
const entryRes = await this.server.getLedgerEntries(
new Contract(this.options.contractId).getFootprint()
);
Expand Down Expand Up @@ -380,21 +598,6 @@ export class AssembledTransaction<T> {
];
};

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
Expand All @@ -421,7 +624,7 @@ export class AssembledTransaction<T> {
* contract's current `persistent` storage expiration date/ledger
* number/block.
*/
expiration?: number | Promise<number>
expiration?: number | Promise<number>
/**
* Sign all auth entries for this account. Default: the account that
* constructed the transaction
Expand Down Expand Up @@ -518,9 +721,9 @@ export class AssembledTransaction<T> {
class SentTransaction<T> {
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 {},
Expand Down
2 changes: 1 addition & 1 deletion src/contract_client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down

0 comments on commit e5c524e

Please sign in to comment.