From c01532ddfd51ad3472a5c80539988b668c0077c2 Mon Sep 17 00:00:00 2001 From: Chad Ostrowski <221614+chadoh@users.noreply.github.com> Date: Thu, 9 May 2024 16:55:54 -0400 Subject: [PATCH] chore(contract-client): appease eslint This includes some fixes and updates to eslint configuration to make it work as expected - Extended `airbnb-typescript/base` to get it to stop yelling at me about importing files without file extension. Saw this recommended as the fix on StackOverflow [[1]]. And it makes sense to me that if we are extending Airbnb's lint rules and using TypeScript, we probably want their TypeScript-specific lint rules, too. - Added the `eslint-plugin-jsdoc` plugin because the old `valid-jsdoc` rule we were using has been deprecated [[2]], and this plugin is the new way. Previously we had `valid-jsdoc: 1` (with some extra customization), and my guess is that extending `plugin:jsdoc/recommended` (plus some customization) is roughly equivalent. - Researched [[3]] whether JSDoc `@param`-style docs or TSDoc-style `/** inline param docs */` work better. TSDoc work better. So disabled `jsdoc/require-param`. [1]: https://stackoverflow.com/a/67610259/249801 [2]: https://eslint.org/docs/latest/rules/valid-jsdoc [3]: https://github.com/stellar/js-stellar-sdk/pull/962#discussion_r1596950321 --- .eslintrc.js | 12 +- package.json | 2 + src/.eslintrc.js | 10 +- src/contract_client/assembled_transaction.ts | 184 +++++++++++-------- src/contract_client/basic_node_signer.ts | 22 ++- src/contract_client/client.ts | 5 +- src/contract_client/index.ts | 12 +- src/contract_client/sent_transaction.ts | 67 ++++--- src/contract_client/types.ts | 29 ++- src/contract_client/utils.ts | 29 ++- yarn.lock | 74 +++++++- 11 files changed, 294 insertions(+), 152 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 2b366f290..1020559fe 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -2,9 +2,17 @@ module.exports = { env: { es6: true, }, - extends: ["airbnb-base", "prettier"], + extends: [ + "airbnb-base", + "airbnb-typescript/base", + "prettier", + "plugin:jsdoc/recommended", + ], plugins: ["@babel", "prettier", "prefer-import"], - parser: "@typescript-eslint/parser", + parserOptions: { + parser: "@typescript-eslint/parser", + project: "./config/tsconfig.json", + }, rules: { "node/no-unpublished-require": 0, }, diff --git a/package.json b/package.json index e093ae448..db33995ba 100644 --- a/package.json +++ b/package.json @@ -109,8 +109,10 @@ "dotenv": "^16.4.5", "eslint": "^8.57.0", "eslint-config-airbnb-base": "^15.0.0", + "eslint-config-airbnb-typescript": "^18.0.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-import": "^2.29.1", + "eslint-plugin-jsdoc": "^48.2.4", "eslint-plugin-node": "^11.1.0", "eslint-plugin-prefer-import": "^0.0.1", "eslint-plugin-prettier": "^5.1.2", diff --git a/src/.eslintrc.js b/src/.eslintrc.js index 7ceb3272d..499625efa 100644 --- a/src/.eslintrc.js +++ b/src/.eslintrc.js @@ -10,9 +10,12 @@ module.exports = { camelcase: 0, "class-methods-use-this": 0, "linebreak-style": 0, + "jsdoc/require-returns": 0, + "jsdoc/require-param": 0, "new-cap": 0, "no-param-reassign": 0, "no-underscore-dangle": 0, + "no-unused-vars": 0, "no-use-before-define": 0, "prefer-destructuring": 0, "lines-between-class-members": 0, @@ -21,14 +24,7 @@ module.exports = { "prefer-import/prefer-import-over-require": [1], "no-console": ["warn", { allow: ["assert"] }], "no-debugger": 1, - "no-unused-vars": 1, "arrow-body-style": 1, - "valid-jsdoc": [ - 1, - { - requireReturnDescription: false, - }, - ], "prefer-const": 1, "object-shorthand": 1, "require-await": 1, diff --git a/src/contract_client/assembled_transaction.ts b/src/contract_client/assembled_transaction.ts index 120f1bffd..fad736533 100644 --- a/src/contract_client/assembled_transaction.ts +++ b/src/contract_client/assembled_transaction.ts @@ -1,9 +1,13 @@ +/* disable max-classes rule, because extending error shouldn't count! */ +/* eslint max-classes-per-file: 0 */ import type { AssembledTransactionOptions, ContractClientOptions, + MethodOptions, Tx, XDR_BASE64, } from "./types"; +import type { ContractClient } from "./client"; import { Account, BASE_FEE, @@ -19,11 +23,12 @@ import { Err } from "../rust_types"; import { DEFAULT_TIMEOUT, contractErrorPattern, - implementsToString + implementsToString, } from "./utils"; import { SentTransaction } from "./sent_transaction"; -export const NULL_ACCOUNT = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF" +export const NULL_ACCOUNT = + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; /** * The main workhorse of {@link ContractClient}. This class is used to wrap a @@ -48,12 +53,17 @@ export const NULL_ACCOUNT = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA * ```ts * const { result } = await AssembledTransaction.build({ * method: 'myReadMethod', - * args: spec.funcArgsToScVals('myReadMethod', { args: 'for', my: 'method', ... }), + * 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), + * publicKey: undefined, // irrelevant, for simulation-only read calls + * parseResultXdr: (result: xdr.ScVal) => + * spec.funcResToNative('myReadMethod', result), * }) * ``` * @@ -61,7 +71,11 @@ export const NULL_ACCOUNT = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA * conjunction with {@link ContractClient}, which simplifies it to: * * ```ts - * const { result } = await client.myReadMethod({ args: 'for', my: 'method', ... }) + * const { result } = await client.myReadMethod({ + * args: 'for', + * my: 'method', + * ... + * }) * ``` * * # 2. Simple write call @@ -70,7 +84,11 @@ export const NULL_ACCOUNT = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA * further manipulation, only one more step is needed: * * ```ts - * const assembledTx = await client.myWriteMethod({ args: 'for', my: 'method', ... }) + * const assembledTx = await client.myWriteMethod({ + * args: 'for', + * my: 'method', + * ... + * }) * const sentTx = await assembledTx.signAndSend() * ``` * @@ -124,7 +142,8 @@ export const NULL_ACCOUNT = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA * await tx.simulate() * ``` * - * If you need to inspect the simulation later, you can access it with `tx.simulation`. + * If you need to inspect the simulation later, you can access it with + * `tx.simulation`. * * # 4. Multi-auth workflows * @@ -230,12 +249,14 @@ export class AssembledTransaction { * ``` */ public raw?: TransactionBuilder; + /** * 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. */ public built?: Tx; + /** * 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 @@ -244,28 +265,33 @@ export class AssembledTransaction { * logic. */ public simulation?: SorobanRpc.Api.SimulateTransactionResponse; + /** * Cached simulation result. This is set after the first call to - * {@link simulationData}, and is used to facilitate serialization and - * deserialization of the AssembledTransaction. + * {@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`. + * 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 - * {@link simulationData}, and is used to facilitate serialization and - * deserialization of the AssembledTransaction. + * {@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`. + * 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; + /** * The Soroban server to use for all RPC calls. This is constructed from the * `rpcUrl` in the options. @@ -285,7 +311,7 @@ export class AssembledTransaction { NoSigner: class NoSignerError extends Error {}, NotYetSimulated: class NotYetSimulatedError extends Error {}, FakeAccount: class FakeAccountError extends Error {}, - } + }; /** * Serialize the AssembledTransaction to a JSON string. This is useful for @@ -319,19 +345,19 @@ export class AssembledTransaction { retval: XDR_BASE64; }; simulationTransactionData: XDR_BASE64; - } + }, ): AssembledTransaction { const txn = new AssembledTransaction(options); - txn.built = TransactionBuilder.fromXDR(tx, options.networkPassphrase) as Tx + txn.built = TransactionBuilder.fromXDR(tx, options.networkPassphrase) as Tx; txn.simulationResult = { auth: simulationResult.auth.map((a) => - xdr.SorobanAuthorizationEntry.fromXDR(a, "base64") + xdr.SorobanAuthorizationEntry.fromXDR(a, "base64"), ), retval: xdr.ScVal.fromXDR(simulationResult.retval, "base64"), }; txn.simulationTransactionData = xdr.SorobanTransactionData.fromXDR( simulationTransactionData, - "base64" + "base64", ); return txn; } @@ -362,7 +388,7 @@ export class AssembledTransaction { * }) */ static async build( - options: AssembledTransactionOptions + options: AssembledTransactionOptions, ): Promise> { const tx = new AssembledTransaction(options); const contract = new Contract(options.contractId); @@ -386,8 +412,8 @@ export class AssembledTransaction { simulate = async (): Promise => { if (!this.raw) { throw new Error( - 'Transaction has not yet been assembled; ' + - 'call `AssembledTransaction.build` first.' + "Transaction has not yet been assembled; " + + "call `AssembledTransaction.build` first.", ); } @@ -397,7 +423,7 @@ export class AssembledTransaction { if (SorobanRpc.Api.isSimulationSuccess(this.simulation)) { this.built = SorobanRpc.assembleTransaction( this.built, - this.simulation + this.simulation, ).build(); } @@ -416,7 +442,9 @@ export class AssembledTransaction { } const simulation = this.simulation!; if (!simulation) { - throw new AssembledTransaction.Errors.NotYetSimulated("Transaction has not yet been simulated"); + throw new AssembledTransaction.Errors.NotYetSimulated( + "Transaction has not yet been simulated", + ); } if (SorobanRpc.Api.isSimulationError(simulation)) { throw new Error(`Transaction simulation failed: "${simulation.error}"`); @@ -427,8 +455,8 @@ export class AssembledTransaction { `You need to restore some contract state before you can invoke this method. ${JSON.stringify( simulation, null, - 2 - )}` + 2, + )}`, ); } @@ -437,8 +465,8 @@ export class AssembledTransaction { `Expected an invocation simulation, but got no 'result' field. Simulation: ${JSON.stringify( simulation, null, - 2 - )}` + 2, + )}`, ); } @@ -457,9 +485,9 @@ export class AssembledTransaction { return this.options.parseResultXdr(this.simulationData.result.retval); } catch (e) { if (!implementsToString(e)) throw e; - let err = this.parseError(e.toString()); + const err = this.parseError(e.toString()); if (err) return err as T; - throw e; + throw e; // eslint-disable-line } } @@ -467,8 +495,8 @@ export class AssembledTransaction { if (!this.options.errorTypes) return undefined; const match = errorMessage.match(contractErrorPattern); if (!match) return undefined; - let i = parseInt(match[1], 10); - let err = this.options.errorTypes[i]; + const i = parseInt(match[1], 10); + const err = this.options.errorTypes[i]; if (!err) return undefined; return new Err(err); } @@ -485,11 +513,11 @@ export class AssembledTransaction { signTransaction = this.options.signTransaction, }: { /** - * If `true`, sign and send the transaction even if it is a read call. + * TSDoc: If `true`, sign and send the transaction even if it is a read call */ force?: boolean; /** - * You must provide this here if you did not provide one before + * TSDoc: You must provide this here if you did not provide one before */ signTransaction?: ContractClientOptions["signTransaction"]; } = {}): Promise> => { @@ -500,31 +528,32 @@ 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 ContractClient" + "`signAndSend` or when initializing your ContractClient", ); } - if ((await this.needsNonInvokerSigningBy()).length) { + 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 - return await SentTransaction.init(signTransaction, typeChecked); + const typeChecked: AssembledTransaction = this; + const sent = await SentTransaction.init(signTransaction, typeChecked); + return sent; }; private getStorageExpiration = async () => { const entryRes = await this.server.getLedgerEntries( - new Contract(this.options.contractId).getFootprint() + new Contract(this.options.contractId).getFootprint(), ); if ( !entryRes.entries || @@ -551,17 +580,18 @@ export class AssembledTransaction { * 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 signAuthEntries}. Then re-serialize and send to the next account - * in this list. + * {@link AssembledTransaction#signAuthEntries}. Then re-serialize and send to + * the next account in this list. */ - needsNonInvokerSigningBy = async ({ + needsNonInvokerSigningBy = ({ includeAlreadySigned = false, }: { /** - * Whether or not to include auth entries that have already been signed. Default: false + * Whether or not to include auth entries that have already been signed. + * Default: false */ includeAlreadySigned?: boolean; - } = {}): Promise => { + } = {}): string[] => { if (!this.built) { throw new Error("Transaction has not yet been simulated"); } @@ -572,8 +602,8 @@ export class AssembledTransaction { if (!("operations" in this.built)) { throw new Error( `Unexpected Transaction type; no operations: ${JSON.stringify( - this.built - )}` + this.built, + )}`, ); } const rawInvokeHostFunctionOp = this.built @@ -588,31 +618,32 @@ export class AssembledTransaction { xdr.SorobanCredentialsType.sorobanCredentialsAddress() && (includeAlreadySigned || entry.credentials().address().signature().switch().name === - "scvVoid") + "scvVoid"), ) .map((entry) => StrKey.encodeEd25519PublicKey( - entry.credentials().address().address().accountId().ed25519() - ) - ) + entry.credentials().address().address().accountId().ed25519(), + ), + ), ), ]; }; /** - * 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 - * in the map, deserialize with `txFromJSON`, and call this method on their - * machine. Internally, this will use `signAuthEntry` function from connected - * `wallet` for each. + * 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 sign} the transaction - * envelope and {@link send} it to the network. + * 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! + * Sending to all `needsNonInvokerSigningBy` owners in parallel is not + * currently supported! */ signAuthEntries = async ({ expiration = this.getStorageExpiration(), @@ -625,7 +656,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 @@ -640,22 +671,22 @@ export class AssembledTransaction { } = {}): Promise => { if (!this.built) throw new Error("Transaction has not yet been assembled or simulated"); - const needsNonInvokerSigningBy = await this.needsNonInvokerSigningBy(); + const needsNonInvokerSigningBy = this.needsNonInvokerSigningBy(); if (!needsNonInvokerSigningBy) { throw new AssembledTransaction.Errors.NoUnsignedNonInvokerAuthEntries( - "No unsigned non-invoker auth entries; maybe you already signed?" + "No unsigned non-invoker auth entries; maybe you already signed?", ); } - if (needsNonInvokerSigningBy.indexOf(publicKey ?? '') === -1) { + if (needsNonInvokerSigningBy.indexOf(publicKey ?? "") === -1) { throw new AssembledTransaction.Errors.NoSignatureNeeded( - `No auth entries for public key "${publicKey}"` + `No auth entries for public key "${publicKey}"`, ); } if (!signAuthEntry) { throw new AssembledTransaction.Errors.NoSigner( - 'You must provide `signAuthEntry` when calling `signAuthEntries`, ' + - 'or when constructing the `ContractClient` or `AssembledTransaction`' + "You must provide `signAuthEntry` when calling `signAuthEntries`, " + + "or when constructing the `ContractClient` or `AssembledTransaction`", ); } @@ -664,6 +695,7 @@ export class AssembledTransaction { const authEntries = rawInvokeHostFunctionOp.auth ?? []; + // eslint-disable-next-line no-restricted-syntax for (const [i, entry] of authEntries.entries()) { if ( entry.credentials().switch() !== @@ -672,25 +704,23 @@ export class AssembledTransaction { // if the invoker/source account, then the entry doesn't need explicit // signature, since the tx envelope is already signed by the source // account, so only check for sorobanCredentialsAddress - continue; + continue; // eslint-disable-line no-continue } const pk = StrKey.encodeEd25519PublicKey( - entry.credentials().address().address().accountId().ed25519() + entry.credentials().address().address().accountId().ed25519(), ); // this auth entry needs to be signed by a different account // (or maybe already was!) - if (pk !== publicKey) continue; + if (pk !== publicKey) continue; // eslint-disable-line no-continue + // eslint-disable-next-line no-await-in-loop authEntries[i] = await authorizeEntry( entry, async (preimage) => - Buffer.from( - await signAuthEntry(preimage.toXDR("base64")), - "base64" - ), - await expiration, - this.options.networkPassphrase + Buffer.from(await signAuthEntry(preimage.toXDR("base64")), "base64"), + await expiration, // eslint-disable-line no-await-in-loop + this.options.networkPassphrase, ); } }; diff --git a/src/contract_client/basic_node_signer.ts b/src/contract_client/basic_node_signer.ts index 52fbd46c5..9bfe72192 100644 --- a/src/contract_client/basic_node_signer.ts +++ b/src/contract_client/basic_node_signer.ts @@ -1,4 +1,6 @@ -import { Keypair, TransactionBuilder, hash } from '..' +import { Keypair, TransactionBuilder, hash } from ".."; +import type { AssembledTransaction } from "./assembled_transaction"; +import type { ContractClient } from "./client"; /** * For use with {@link ContractClient} and {@link AssembledTransaction}. @@ -7,15 +9,19 @@ import { Keypair, TransactionBuilder, hash } from '..' * applications. Feel free to use this as a starting point for your own * Wallet/TransactionSigner implementation. */ -export const basicNodeSigner = (keypair: Keypair, networkPassphrase: string) => ({ +export const basicNodeSigner = ( + /** {@link Keypair} to use to sign the transaction or auth entry */ + keypair: Keypair, + /** passphrase of network to sign for */ + networkPassphrase: string, +) => ({ + // eslint-disable-next-line require-await signTransaction: async (tx: string) => { const t = TransactionBuilder.fromXDR(tx, networkPassphrase); t.sign(keypair); return t.toXDR(); }, - signAuthEntry: async (entryXdr: string): Promise => { - return keypair - .sign(hash(Buffer.from(entryXdr, "base64"))) - .toString('base64') - } -}) + // eslint-disable-next-line require-await + signAuthEntry: async (entryXdr: string): Promise => + keypair.sign(hash(Buffer.from(entryXdr, "base64"))).toString("base64"), +}); diff --git a/src/contract_client/client.ts b/src/contract_client/client.ts index 1fa0f2f9a..bca6e225c 100644 --- a/src/contract_client/client.ts +++ b/src/contract_client/client.ts @@ -12,7 +12,9 @@ export class ContractClient { * transaction. */ constructor( + /** {@link ContractSpec} to construct a Client for */ public readonly spec: ContractSpec, + /** see {@link ContractClientOptions} */ public readonly options: ContractClientOptions, ) { this.spec.funcs().forEach((xdrFn) => { @@ -47,7 +49,7 @@ export class ContractClient { txFromJSON = (json: string): AssembledTransaction => { const { method, ...tx } = JSON.parse(json); - return AssembledTransaction.fromJSON( + const t = AssembledTransaction.fromJSON( { ...this.options, method, @@ -56,5 +58,6 @@ export class ContractClient { }, tx, ); + return tx }; } diff --git a/src/contract_client/index.ts b/src/contract_client/index.ts index 53ac515b2..bd0c73fed 100644 --- a/src/contract_client/index.ts +++ b/src/contract_client/index.ts @@ -1,6 +1,6 @@ -export * from './assembled_transaction' -export * from './basic_node_signer' -export * from './client' -export * from './sent_transaction' -export * from './types' -export * from './utils' +export * from "./assembled_transaction"; +export * from "./basic_node_signer"; +export * from "./client"; +export * from "./sent_transaction"; +export * from "./types"; +export * from "./utils"; diff --git a/src/contract_client/sent_transaction.ts b/src/contract_client/sent_transaction.ts index a1276f747..baa5e1f6f 100644 --- a/src/contract_client/sent_transaction.ts +++ b/src/contract_client/sent_transaction.ts @@ -1,7 +1,9 @@ -import type { ContractClientOptions, Tx } from "./types"; +/* disable max-classes rule, because extending error shouldn't count! */ +/* eslint max-classes-per-file: 0 */ +import type { ContractClientOptions, MethodOptions, Tx } from "./types"; import { SorobanDataBuilder, SorobanRpc, TransactionBuilder } from ".."; import { DEFAULT_TIMEOUT, withExponentialBackoff } from "./utils"; -import { AssembledTransaction } from "./assembled_transaction"; +import type { AssembledTransaction } from "./assembled_transaction"; /** * A transaction that has been sent to the Soroban network. This happens in two steps: @@ -19,12 +21,15 @@ import { AssembledTransaction } from "./assembled_transaction"; */ export class SentTransaction { public server: SorobanRpc.Server; + public signed?: Tx; + /** * The result of calling `sendTransaction` to broadcast the transaction to the * network. */ public sendTransactionResponse?: SorobanRpc.Api.SendTransactionResponse; + /** * If `sendTransaction` completes successfully (which means it has `status: 'PENDING'`), * then `getTransaction` will be called in a loop for @@ -32,6 +37,7 @@ export class SentTransaction { * the results of those calls. */ public getTransactionResponseAll?: SorobanRpc.Api.GetTransactionResponse[]; + /** * The most recent result of calling `getTransaction`, from the * `getTransactionResponseAll` array. @@ -39,8 +45,9 @@ export class SentTransaction { public getTransactionResponse?: SorobanRpc.Api.GetTransactionResponse; static Errors = { - SendFailed: class SendFailedError extends Error {}, - SendResultOnly: class SendResultOnlyError extends Error {}, + SendFailed: class SendFailedError extends Error { }, + SendResultOnly: class SendResultOnlyError extends Error { }, + TransactionStillPending: class TransactionStillPendingError extends Error { }, }; constructor( @@ -62,20 +69,26 @@ export class SentTransaction { * a `signTransaction` function. This will also send the transaction to the * network. */ - static init = async ( + static init = async ( + /** More info in {@link MethodOptions} */ signTransaction: ContractClientOptions["signTransaction"], - assembled: AssembledTransaction, - ): Promise> => { + /** {@link AssembledTransaction} from which this SentTransaction was initialized */ + assembled: AssembledTransaction, + ): Promise> => { const tx = new SentTransaction(signTransaction, assembled); - return await tx.send(); + const sent = await tx.send(); + return sent; }; private send = async (): Promise => { - const timeoutInSeconds = this.assembled.options.timeoutInSeconds ?? DEFAULT_TIMEOUT + const timeoutInSeconds = + 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() + sorobanData: new SorobanDataBuilder( + this.assembled.simulationData.transactionData.toXDR(), + ).build(), }) .setTimeout(timeoutInSeconds) .build(); @@ -99,8 +112,11 @@ export class SentTransaction { if (this.sendTransactionResponse.status !== "PENDING") { throw new SentTransaction.Errors.SendFailed( - "Sending the transaction to the network failed!\n" + - JSON.stringify(this.sendTransactionResponse, null, 2), + `Sending the transaction to the network failed!\n${JSON.stringify( + this.sendTransactionResponse, + null, + 2, + )}`, ); } @@ -118,19 +134,19 @@ export class SentTransaction { this.getTransactionResponse.status === SorobanRpc.Api.GetTransactionStatus.NOT_FOUND ) { - console.error( + throw new SentTransaction.Errors.TransactionStillPending( `Waited ${timeoutInSeconds} seconds for transaction to complete, but it did not. ` + - `Returning anyway. Check the transaction status manually. ` + - `Sent transaction: ${JSON.stringify( - this.sendTransactionResponse, - null, - 2, - )}\n` + - `All attempts to get the result: ${JSON.stringify( - this.getTransactionResponseAll, - null, - 2, - )}`, + `Returning anyway. Check the transaction status manually. ` + + `Sent transaction: ${JSON.stringify( + this.sendTransactionResponse, + null, + 2, + )}\n` + + `All attempts to get the result: ${JSON.stringify( + this.getTransactionResponseAll, + null, + 2, + )}`, ); } @@ -147,7 +163,8 @@ export class SentTransaction { ); } - // if "returnValue" not present, the transaction failed; return without parsing the result + // if "returnValue" not present, the transaction failed; return without + // parsing the result throw new Error("Transaction failed! Cannot parse result."); } diff --git a/src/contract_client/types.ts b/src/contract_client/types.ts index f80b524d6..5005d28c6 100644 --- a/src/contract_client/types.ts +++ b/src/contract_client/types.ts @@ -1,11 +1,9 @@ -import { - BASE_FEE, - Memo, - MemoType, - Operation, - Transaction, - xdr, -} from ".."; +/* disable PascalCase naming convention, to avoid breaking change */ +/* eslint-disable @typescript-eslint/naming-convention */ +import { BASE_FEE, Memo, MemoType, Operation, Transaction, xdr } from ".."; +import type { ContractClient } from "./client"; +import type { AssembledTransaction } from "./assembled_transaction"; +import { DEFAULT_TIMEOUT } from "./utils"; export type XDR_BASE64 = string; export type u32 = number; @@ -49,7 +47,7 @@ export type ContractClientOptions = { network?: string; networkPassphrase?: string; accountToSign?: string; - } + }, ) => Promise; /** * A function to sign a specific auth entry for a transaction, using the @@ -64,7 +62,7 @@ export type ContractClientOptions = { entryXdr: XDR_BASE64, opts?: { accountToSign?: string; - } + }, ) => Promise; contractId: string; networkPassphrase: string; @@ -75,8 +73,8 @@ export type ContractClientOptions = { */ allowHttp?: boolean; /** - * This gets filled in automatically from the ContractSpec if you use - * {@link ContractClient.generate} to create your ContractClient. + * This gets filled in automatically from the ContractSpec when you + * instantiate a {@link ContractClient}. * * Background: If the contract you're calling uses the `#[contracterror]` * macro to create an `Error` enum, then those errors get included in the @@ -101,12 +99,14 @@ export type MethodOptions = { fee?: string; /** - * The maximum amount of time to wait for the transaction to complete. Default: {@link DEFAULT_TIMEOUT} + * The maximum amount of time to wait for the transaction to complete. + * Default: {@link DEFAULT_TIMEOUT} */ timeoutInSeconds?: number; /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true + * Whether to automatically simulate the transaction when constructing the + * AssembledTransaction. Default: true */ simulate?: boolean; }; @@ -117,4 +117,3 @@ export type AssembledTransactionOptions = MethodOptions & args?: any[]; parseResultXdr: (xdr: xdr.ScVal) => T; }; - diff --git a/src/contract_client/utils.ts b/src/contract_client/utils.ts index b9d3d6603..2ef733f98 100644 --- a/src/contract_client/utils.ts +++ b/src/contract_client/utils.ts @@ -1,17 +1,24 @@ +import type { AssembledTransaction } from "./assembled_transaction"; + /** * The default timeout for waiting for a transaction to be included in a block. */ export const DEFAULT_TIMEOUT = 5 * 60; /** - * Keep calling a `fn` for `timeoutInSeconds` seconds, if `keepWaitingIf` is true. - * Returns an array of all attempts to call the function. + * Keep calling a `fn` for `timeoutInSeconds` seconds, if `keepWaitingIf` is + * true. Returns an array of all attempts to call the function. */ export async function withExponentialBackoff( + /** Function to call repeatedly */ fn: (previousFailure?: T) => Promise, + /** Condition to check when deciding whether or not to call `fn` again */ keepWaitingIf: (result: T) => boolean, + /** How long to wait between the first and second call */ timeoutInSeconds: number, + /** What to multiply `timeoutInSeconds` by, each subsequent attempt */ exponentialFactor = 1.5, + /** Whether to log extra info */ verbose = false, ): Promise { const attempts: T[] = []; @@ -28,31 +35,34 @@ export async function withExponentialBackoff( Date.now() < waitUntil && keepWaitingIf(attempts[attempts.length - 1]) ) { - count++; + count += 1; // Wait a beat if (verbose) { + // eslint-disable-next-line no-console console.info( - `Waiting ${waitTime}ms before trying again (bringing the total wait time to ${totalWaitTime}ms so far, of total ${ - timeoutInSeconds * 1000 + `Waiting ${waitTime}ms before trying again (bringing the total wait time to ${totalWaitTime}ms so far, of total ${timeoutInSeconds * 1000 }ms)`, ); } + // eslint-disable-next-line await new Promise((res) => setTimeout(res, waitTime)); // Exponential backoff - waitTime = waitTime * exponentialFactor; + waitTime *= exponentialFactor; if (new Date(Date.now() + waitTime).valueOf() > waitUntil) { waitTime = waitUntil - Date.now(); if (verbose) { + // eslint-disable-next-line no-console console.info(`was gonna wait too long; new waitTime: ${waitTime}ms`); } } totalWaitTime = waitTime + totalWaitTime; // Try again + // eslint-disable-next-line no-await-in-loop attempts.push(await fn(attempts[attempts.length - 1])); if (verbose && keepWaitingIf(attempts[attempts.length - 1])) { + // eslint-disable-next-line no-console console.info( - `${count}. Called ${fn}; ${ - attempts.length + `${count}. Called ${fn}; ${attempts.length } prev attempts. Most recent: ${JSON.stringify( attempts[attempts.length - 1], null, @@ -70,7 +80,7 @@ export async function withExponentialBackoff( * errors get included in the on-chain XDR that also describes your contract's * methods. Each error will have a specific number. This Regular Expression * matches these "expected error types" that a contract may throw, and helps - * @{link AssembledTransaction} parse these errors. + * {@link AssembledTransaction} parse these errors. */ export const contractErrorPattern = /Error\(Contract, #(\d+)\)/; @@ -78,6 +88,7 @@ export const contractErrorPattern = /Error\(Contract, #(\d+)\)/; * A TypeScript type guard that checks if an object has a `toString` method. */ export function implementsToString( + /** some object that may or may not have a `toString` method */ obj: unknown, ): obj is { toString(): string } { return typeof obj === "object" && obj !== null && "toString" in obj; diff --git a/yarn.lock b/yarn.lock index 63e94404e..5eb23e6c0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1132,6 +1132,18 @@ resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw== +"@es-joy/jsdoccomment@~0.43.0": + version "0.43.0" + resolved "https://registry.yarnpkg.com/@es-joy/jsdoccomment/-/jsdoccomment-0.43.0.tgz#35c295cadd0a939d1a3a6cd1548f66ec76d38870" + integrity sha512-Q1CnsQrytI3TlCB1IVWXWeqUIPGVEKGaE7IbVdt13Nq/3i0JESAkQQERrfiQkmlpijl+++qyqPgaS31Bvc1jRQ== + dependencies: + "@types/eslint" "^8.56.5" + "@types/estree" "^1.0.5" + "@typescript-eslint/types" "^7.2.0" + comment-parser "1.4.1" + esquery "^1.5.0" + jsdoc-type-pratt-parser "~4.0.0" + "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": version "4.4.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" @@ -1665,7 +1677,7 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-6.21.0.tgz#205724c5123a8fef7ecd195075fa6e85bac3436d" integrity sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg== -"@typescript-eslint/types@7.8.0": +"@typescript-eslint/types@7.8.0", "@typescript-eslint/types@^7.2.0": version "7.8.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-7.8.0.tgz#1fd2577b3ad883b769546e2d1ef379f929a7091d" integrity sha512-wf0peJ+ZGlcH+2ZS23aJbOv+ztjeeP8uQ9GgwMJGVLx/Nj9CJt17GWgWWoSmoRVKAX2X+7fzEnAjxdvK2gqCLw== @@ -2042,6 +2054,11 @@ archy@^1.0.0: resolved "https://registry.yarnpkg.com/archy/-/archy-1.0.0.tgz#f9c8c13757cc1dd7bc379ac77b2c62a5c2868c40" integrity sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw== +are-docs-informative@^0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/are-docs-informative/-/are-docs-informative-0.0.2.tgz#387f0e93f5d45280373d387a59d34c96db321963" + integrity sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig== + are-we-there-yet@~1.1.2: version "1.1.7" resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz#b15474a932adab4ff8a50d9adfa7e4e926f21146" @@ -2533,6 +2550,11 @@ buffer@^6.0.3: base64-js "^1.3.1" ieee754 "^1.2.1" +builtin-modules@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6" + integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw== + builtin-status-codes@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8" @@ -2948,6 +2970,11 @@ commander@~2.1.0: resolved "https://registry.yarnpkg.com/commander/-/commander-2.1.0.tgz#d121bbae860d9992a3d517ba96f56588e47c6781" integrity sha512-J2wnb6TKniXNOtoHS8TSrG9IOQluPrsmyAJ8oCUJOBmv+uLBCyPYAZkD2jFvw2DCzIXNnISIM01NIvr35TkBMQ== +comment-parser@1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/comment-parser/-/comment-parser-1.4.1.tgz#bdafead37961ac079be11eb7ec65c4d021eaf9cc" + integrity sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg== + common-path-prefix@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/common-path-prefix/-/common-path-prefix-3.0.0.tgz#7d007a7e07c58c4b4d5f433131a19141b29f11e0" @@ -3634,6 +3661,13 @@ eslint-config-airbnb-base@^15.0.0: object.entries "^1.1.5" semver "^6.3.0" +eslint-config-airbnb-typescript@^18.0.0: + version "18.0.0" + resolved "https://registry.yarnpkg.com/eslint-config-airbnb-typescript/-/eslint-config-airbnb-typescript-18.0.0.tgz#b1646db4134858d704b1d2bee47e1d72c180315f" + integrity sha512-oc+Lxzgzsu8FQyFVa4QFaVKiitTYiiW3frB9KYW5OWdPrqFc7FzxgB20hP4cHMlr+MBzGcLl3jnCOVOydL9mIg== + dependencies: + eslint-config-airbnb-base "^15.0.0" + eslint-config-prettier@^9.0.0: version "9.1.0" resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz#31af3d94578645966c082fcb71a5846d3c94867f" @@ -3686,6 +3720,21 @@ eslint-plugin-import@^2.29.1: semver "^6.3.1" tsconfig-paths "^3.15.0" +eslint-plugin-jsdoc@^48.2.4: + version "48.2.4" + resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-48.2.4.tgz#0b6972faa9e5de89a08f1b0bcdc30e70a9cad736" + integrity sha512-3ebvVgCJFy06gpmuS2ynz13uh9iFSzZ1C1dDkgcSAqVVg82zlORKMk2fvjq708pAO6bwfs5YLttknFEbaoDiGw== + dependencies: + "@es-joy/jsdoccomment" "~0.43.0" + are-docs-informative "^0.0.2" + comment-parser "1.4.1" + debug "^4.3.4" + escape-string-regexp "^4.0.0" + esquery "^1.5.0" + is-builtin-module "^3.2.1" + semver "^7.6.0" + spdx-expression-parse "^4.0.0" + eslint-plugin-node@^11.1.0: version "11.1.0" resolved "https://registry.yarnpkg.com/eslint-plugin-node/-/eslint-plugin-node-11.1.0.tgz#c95544416ee4ada26740a30474eefc5402dc671d" @@ -3818,7 +3867,7 @@ esprima@^4.0.0: resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== -esquery@^1.4.2: +esquery@^1.4.2, esquery@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.5.0.tgz#6ce17738de8577694edd7361c57182ac8cb0db0b" integrity sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg== @@ -4701,6 +4750,13 @@ is-buffer@^2.0.5: resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.5.tgz#ebc252e400d22ff8d77fa09888821a24a658c191" integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ== +is-builtin-module@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-3.2.1.tgz#f03271717d8654cfcaf07ab0463faa3571581169" + integrity sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A== + dependencies: + builtin-modules "^3.3.0" + is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7: version "1.2.7" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" @@ -5093,6 +5149,11 @@ jsbn@~0.1.0: resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" integrity sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg== +jsdoc-type-pratt-parser@~4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.0.0.tgz#136f0571a99c184d84ec84662c45c29ceff71114" + integrity sha512-YtOli5Cmzy3q4dP26GraSOeAhqecewG04hoO8DY56CH4KJ9Fvv5qKWUCCo3HZob7esJQHCv6/+bnTy72xZZaVQ== + jsdoc@^4.0.2: version "4.0.3" resolved "https://registry.yarnpkg.com/jsdoc/-/jsdoc-4.0.3.tgz#bfee86c6a82f6823e12b5e8be698fd99ae46c061" @@ -7113,6 +7174,14 @@ spdx-expression-parse@^3.0.0: spdx-exceptions "^2.1.0" spdx-license-ids "^3.0.0" +spdx-expression-parse@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz#a23af9f3132115465dac215c099303e4ceac5794" + integrity sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ== + dependencies: + spdx-exceptions "^2.1.0" + spdx-license-ids "^3.0.0" + spdx-license-ids@^3.0.0: version "3.0.17" resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.17.tgz#887da8aa73218e51a1d917502d79863161a93f9c" @@ -7721,6 +7790,7 @@ typedarray@^0.0.6: integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw== "typescript-5.4@npm:typescript@~5.4.0-0", typescript@^5.4.3: + name typescript-5.4 version "5.4.5" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.4.5.tgz#42ccef2c571fdbd0f6718b1d1f5e6e5ef006f611" integrity sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==