From b22af74974c1134d37e8c273a831f4de3f1cdb6e Mon Sep 17 00:00:00 2001 From: Chad Ostrowski <221614+chadoh@users.noreply.github.com> Date: Wed, 7 Feb 2024 16:35:08 -0500 Subject: [PATCH] optional simulate & wallet, editable TransactionBuilder - Can now pass an `account` OR `wallet` when constructing the ContractClient, or none! If you pass none, you can still make view calls, since they don't need a signer. You will need to pass a `wallet` when calling things that need it, like `signAndSend`. - You can now pass `simulate: false` when first creating your transaction to skip simulation. You can then modify the transaction using the TransactionBuilder at `tx.raw` before manually calling `simulate`. Example: const tx = await myContract.myMethod( { args: 'for', my: 'method', ... }, { simulate: false } ); tx.raw.addMemo(Memo.text('Nice memo, friend!')) await tx.simulate(); - Error types are now collected under `AssembledTransaction.Errors` and `SentTransaction.Errors`. --- src/contract_spec.ts | 2 +- src/soroban/assembled_transaction.ts | 340 +++++++++++++++------------ src/soroban/contract_client.ts | 60 ++++- test/e2e/initialize.sh | 3 - test/e2e/src/test-hello-world.js | 2 +- test/e2e/src/test-swap.js | 2 +- test/e2e/src/util.js | 1 + 7 files changed, 240 insertions(+), 170 deletions(-) diff --git a/src/contract_spec.ts b/src/contract_spec.ts index d7538e707..60a2ac87a 100644 --- a/src/contract_spec.ts +++ b/src/contract_spec.ts @@ -719,7 +719,7 @@ export class ContractSpec { args: Record, options: MethodOptions ) => { - return await AssembledTransaction.fromSimulation({ + return await AssembledTransaction.build({ method: name, args: spec.funcArgsToScVals(name, args), ...options, diff --git a/src/soroban/assembled_transaction.ts b/src/soroban/assembled_transaction.ts index ccc368ef9..634099624 100644 --- a/src/soroban/assembled_transaction.ts +++ b/src/soroban/assembled_transaction.ts @@ -1,6 +1,11 @@ -import type { ContractClientOptions, XDR_BASE64 } from "."; +import type { + ContractClientOptions, + AcceptsWalletOrAccount, + Wallet, + XDR_BASE64, +} from "."; +import { NULL_ACCOUNT } from "."; import { - Account, BASE_FEE, Contract, Operation, @@ -11,7 +16,8 @@ import { hash, xdr, } from ".."; -import type { Memo, MemoType, Transaction } from ".."; +import { getAccount } from "./contract_client"; +import { Memo, MemoType, Transaction } from ".."; type Tx = Transaction, Operation[]>; @@ -48,7 +54,7 @@ class Err implements Result { isErr() { return true } } -export type MethodOptions = { +export type MethodOptions = AcceptsWalletOrAccount & { /** * The fee to pay for the transaction. Default: BASE_FEE */ @@ -57,6 +63,11 @@ export type MethodOptions = { * 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 + */ + simulate?: boolean; }; const DEFAULT_TIMEOUT = 10; @@ -68,22 +79,53 @@ export type AssembledTransactionOptions = MethodOptions & parseResultXdr: (xdr: xdr.ScVal) => T; }; -export const NULL_ACCOUNT = - "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; - export class AssembledTransaction { - public raw?: Tx; + /** + * 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(); + * ``` + */ + 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 included in the `toJSON` and `fromJSON` methods. See `simulationResult` and `simulationTransactionData` for cached, serializable versions of the data needed by AssembledTransaction logic. + */ private simulation?: SorobanRpc.Api.SimulateTransactionResponse; + /** + * Cached simulation result. This is set after the first call to `simulationData`, and is used to facilitate serialization and deserialization of the AssembledTransaction. + */ 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 deserialization of the AssembledTransaction. + */ private simulationTransactionData?: xdr.SorobanTransactionData; + /** + * The Soroban server to use for all RPC calls. This is constructed from the `rpcUrl` in the options. + */ private server: SorobanRpc.Server; - static ExpiredStateError = class ExpiredStateError extends Error {} - static NeedsMoreSignaturesError = class NeedsMoreSignaturesError extends Error {} - static WalletDisconnectedError = class WalletDisconnectedError extends Error {} - static SendResultOnlyError = class SendResultOnlyError extends Error {} - static SendFailedError = class SendFailedError extends Error {} - static NoUnsignedNonInvokerAuthEntriesError = class NoUnsignedNonInvokerAuthEntriesError extends Error {} + /** + * 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: class ExpiredStateError extends Error {}, + NeedsMoreSignatures: class NeedsMoreSignaturesError extends Error {}, + NoSignatureNeeded: class NoSignatureNeededError extends Error {}, + NoUnsignedNonInvokerAuthEntries: class NoUnsignedNonInvokerAuthEntriesError extends Error {}, + NoWallet: class NoWalletError extends Error {}, + NotYetSimulated: class NotYetSimulatedError extends Error {}, + WalletDisconnected: class WalletDisconnectedError extends Error {}, + } /** * A minimal implementation of Rust's `Result` type. Used for contract methods that return Results, to maintain their distinction from methods that simply either return a value or throw. @@ -94,15 +136,18 @@ export class AssembledTransaction { */ Ok, /** - * A minimal implementation of Rust's `Err` Result type. Used for contract methods that return unsuccessful Results, to maintain their distinction from methods that simply either return a value or throw. + * A minimal implementation of Rust's `Error` Result type. Used for contract methods that return unsuccessful Results, to maintain their distinction from methods that simply either return a value or throw. */ Err } + /** + * 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.raw?.toXDR(), + tx: this.built?.toXDR(), simulationResult: { auth: this.simulationData.result.auth.map((a) => a.toXDR("base64")), retval: this.simulationData.result.retval.toXDR("base64"), @@ -128,7 +173,7 @@ export class AssembledTransaction { } ): AssembledTransaction { const txn = new AssembledTransaction(options); - txn.raw = 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") @@ -143,35 +188,46 @@ export class AssembledTransaction { } private constructor(public options: AssembledTransactionOptions) { + this.options.simulate = this.options.simulate ?? true; this.server = new SorobanRpc.Server(this.options.rpcUrl, { allowHttp: this.options.rpcUrl.startsWith("http://"), }); } - static async fromSimulation( + static async build( options: AssembledTransactionOptions ): Promise> { const tx = new AssembledTransaction(options); const contract = new Contract(options.contractId); - tx.raw = new TransactionBuilder(await tx.getAccount(), { + options.account = options.account ?? getAccount(tx.server, options.wallet); + + tx.raw = new TransactionBuilder(await options.account, { fee: options.fee?.toString(10) ?? BASE_FEE, networkPassphrase: options.networkPassphrase, }) .addOperation(contract.call(options.method, ...(options.args ?? []))) - .setTimeout(options.timeoutInSeconds ?? DEFAULT_TIMEOUT) - .build(); + .setTimeout(options.timeoutInSeconds ?? DEFAULT_TIMEOUT); - return await tx.simulate(); + if (options.simulate) await tx.simulate(); + + return tx; } simulate = async (): Promise => { - if (!this.raw) throw new Error("Transaction has not yet been assembled"); - this.simulation = await this.server.simulateTransaction(this.raw); + if (!this.raw) { + throw new Error( + 'Transaction has not yet been assembled; ' + + 'call `AssembledTransaction.build` first.' + ); + } + + this.built = this.raw.build(); + this.simulation = await this.server.simulateTransaction(this.built); if (SorobanRpc.Api.isSimulationSuccess(this.simulation)) { - this.raw = SorobanRpc.assembleTransaction( - this.raw, + this.built = SorobanRpc.assembleTransaction( + this.built, this.simulation ).build(); } @@ -189,14 +245,16 @@ export class AssembledTransaction { transactionData: this.simulationTransactionData, }; } - // else, we know we just did the simulation on this machine const simulation = this.simulation!; + if (!simulation) { + throw new AssembledTransaction.Errors.NotYetSimulated("Transaction has not yet been simulated"); + } if (SorobanRpc.Api.isSimulationError(simulation)) { throw new Error(`Transaction simulation failed: "${simulation.error}"`); } if (SorobanRpc.Api.isSimulationRestore(simulation)) { - throw new AssembledTransaction.ExpiredStateError( + throw new AssembledTransaction.Errors.ExpiredState( `You need to restore some contract state before you can invoke this method. ${JSON.stringify( simulation, null, @@ -246,71 +304,55 @@ export class AssembledTransaction { return new AssembledTransaction.Result.Err(err); } - getPublicKey = async (): Promise => { - const wallet = this.options.wallet; - if (!(await wallet.isConnected()) || !(await wallet.isAllowed())) { - return undefined; - } - return (await wallet.getUserInfo()).publicKey; - }; - /** - * Get account details from the Soroban network for the publicKey currently - * selected in user's wallet. If not connected to Freighter, use placeholder - * null account. - */ - getAccount = async (): Promise => { - const publicKey = await this.getPublicKey(); - return publicKey - ? await this.server.getAccount(publicKey) - : new Account(NULL_ACCOUNT, "0"); - }; - - /** - * Sign the transaction with the `wallet` (default Freighter), then send to - * the network and return a `SentTransaction` that keeps track of all the - * attempts to send and fetch the transaction from the network. + * Sign the transaction with the `wallet`, included previously. If you did + * not previously include one, you need to include one now that at least + * includes the `signTransaction` method. 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. */ signAndSend = async ({ - secondsToWait = 10, force = false, + wallet = this.options.wallet, }: { - /** - * Wait `secondsToWait` seconds (default: 10) for both the transaction to SEND successfully (will keep trying if the server returns `TRY_AGAIN_LATER`), as well as for the transaction to COMPLETE (will keep checking if the server returns `PENDING`). - */ - secondsToWait?: number; /** * If `true`, sign and send the transaction even if it is a read call. */ force?: boolean; + /** + * The wallet to use for signing. If not provided, the wallet from the + * options will be used. You must provide a wallet here if you did not + * provide one before, but the only method it needs to include at this + * point is `signTransaction`. + */ + wallet?: Pick; } = {}): Promise> => { - if (!this.raw) { + if (!this.built) { throw new Error("Transaction has not yet been simulated"); } if (!force && this.isReadCall) { - throw new Error( + throw new AssembledTransaction.Errors.NoSignatureNeeded( "This is a read call. It requires no signature or sending. Use `force: true` to sign and send anyway." ); } - if (!(await this.hasRealInvoker())) { - throw new AssembledTransaction.WalletDisconnectedError("Wallet is not connected"); + if (!wallet) { + throw new AssembledTransaction.Errors.NoWallet("No wallet provided"); } - if (this.raw.source !== (await this.getAccount()).accountId()) { - throw new Error( - `You must submit the transaction with the account that originally created it. Please switch to the wallet with "${this.raw.source}" as its public key.` - ); + if (!(await this.hasRealInvoker())) { + throw new AssembledTransaction.Errors.WalletDisconnected("Wallet is not connected"); } if ((await this.needsNonInvokerSigningBy()).length) { - throw new AssembledTransaction.NeedsMoreSignaturesError( + throw new AssembledTransaction.Errors.NeedsMoreSignatures( "Transaction requires more signatures. See `needsNonInvokerSigningBy` for details." ); } - return await SentTransaction.init(this.options, this, secondsToWait); + const typeChecked: AssembledTransaction = this + return await SentTransaction.init(wallet, typeChecked); }; getStorageExpiration = async () => { @@ -353,21 +395,21 @@ export class AssembledTransaction { */ includeAlreadySigned?: boolean; } = {}): Promise => { - if (!this.raw) { + if (!this.built) { throw new Error("Transaction has not yet been simulated"); } // We expect that any transaction constructed by these libraries has a // single operation, which is an InvokeHostFunction operation. The host // function being invoked is the contract method call. - if (!("operations" in this.raw)) { + if (!("operations" in this.built)) { throw new Error( `Unexpected Transaction type; no operations: ${JSON.stringify( - this.raw + this.built )}` ); } - const rawInvokeHostFunctionOp = this.raw + const rawInvokeHostFunctionOp = this.built .operations[0] as Operation.InvokeHostFunction; return [ @@ -420,33 +462,53 @@ export class AssembledTransaction { * Sending to all `needsNonInvokerSigningBy` owners in parallel is not currently * supported! */ - signAuthEntries = async ( + signAuthEntries = async ({ + expiration = this.getStorageExpiration(), + wallet = this.options.wallet, + }: { /** * When to set each auth entry to expire. Could be any number of blocks in * the future. Can be supplied as a promise or a raw number. Default: * contract's current `persistent` storage expiration date/ledger * number/block. */ - expiration: number | Promise = this.getStorageExpiration() - ): Promise => { - if (!this.raw) + expiration?: number | Promise + /** + * The wallet to use for signing. If not provided, the wallet from the + * options will be used. You must provide a wallet here if you did not + * provide one before, but the only method it needs to include at this + * point is `signAuthEntry`. + */ + wallet?: Pick; + } = {}): Promise => { + if (!this.built) throw new Error("Transaction has not yet been assembled or simulated"); const needsNonInvokerSigningBy = await this.needsNonInvokerSigningBy(); - if (!needsNonInvokerSigningBy) - throw new AssembledTransaction.NoUnsignedNonInvokerAuthEntriesError( + if (!needsNonInvokerSigningBy) { + throw new AssembledTransaction.Errors.NoUnsignedNonInvokerAuthEntries( "No unsigned non-invoker auth entries; maybe you already signed?" ); - const publicKey = await this.getPublicKey(); - if (!publicKey) - throw new Error( + } + const publicKey = (await this.options.account)!.accountId() + if (!publicKey) { + throw new AssembledTransaction.Errors.NoWallet( "Could not get public key from wallet; maybe not signed in?" ); - if (needsNonInvokerSigningBy.indexOf(publicKey) === -1) - throw new Error(`No auth entries for public key "${publicKey}"`); - const wallet = this.options.wallet; + } + if (needsNonInvokerSigningBy.indexOf(publicKey) === -1) { + throw new AssembledTransaction.Errors.NoSignatureNeeded( + `No auth entries for public key "${publicKey}"` + ); + } + if (!wallet) { + throw new AssembledTransaction.Errors.NoWallet( + 'You must either provide a `wallet` when calling `signAuthEntries`, ' + + 'or provide one when constructing the `AssembledTransaction`' + ); + } - const rawInvokeHostFunctionOp = this.raw + const rawInvokeHostFunctionOp = this.built .operations[0] as Operation.InvokeHostFunction; const authEntries = rawInvokeHostFunctionOp.auth ?? []; @@ -492,8 +554,7 @@ export class AssembledTransaction { } hasRealInvoker = async (): Promise => { - const account = await this.getAccount(); - return account.accountId() !== NULL_ACCOUNT; + return (await this.options.account!).accountId() !== NULL_ACCOUNT; }; } @@ -515,88 +576,57 @@ class SentTransaction { public server: SorobanRpc.Server; public signed?: Tx; public sendTransactionResponse?: SendTx; - public sendTransactionResponseAll?: SendTx[]; public getTransactionResponse?: GetTx; public getTransactionResponseAll?: GetTx[]; + static Errors = { + SendFailed: class SendFailedError extends Error {}, + SendResultOnly: class SendResultOnlyError extends Error {}, + } + constructor( - public options: AssembledTransactionOptions, + public wallet: Pick, public assembled: AssembledTransaction ) { - this.server = new SorobanRpc.Server(this.options.rpcUrl, { - allowHttp: this.options.rpcUrl.startsWith("http://"), + this.server = new SorobanRpc.Server(this.assembled.options.rpcUrl, { + allowHttp: this.assembled.options.rpcUrl.startsWith("http://"), }); - this.assembled = assembled; } static init = async ( - options: AssembledTransactionOptions, + wallet: Pick, assembled: AssembledTransaction, - secondsToWait: number = 10 ): Promise> => { - const tx = new SentTransaction(options, assembled); - return await tx.send(secondsToWait); + const tx = new SentTransaction(wallet, assembled); + return await tx.send(); }; - private send = async (secondsToWait: number = 10): Promise => { - const wallet = this.assembled.options.wallet; - - this.sendTransactionResponseAll = await withExponentialBackoff( - async (previousFailure) => { - if (previousFailure) { - // Increment transaction sequence number and resimulate before trying again - - // Soroban transaction can only have 1 operation - const op = this.assembled.raw! - .operations[0] as Operation.InvokeHostFunction; + private send = async (): Promise => { + const timeoutInSeconds = this.assembled.options.timeoutInSeconds ?? DEFAULT_TIMEOUT - this.assembled.raw = new TransactionBuilder( - await this.assembled.getAccount(), - { - fee: this.assembled.raw!.fee, - networkPassphrase: this.options.networkPassphrase, - } - ) - .setTimeout(this.assembled.options.timeoutInSeconds ?? DEFAULT_TIMEOUT) - .addOperation( - Operation.invokeHostFunction({ ...op, auth: op.auth ?? [] }) - ) - .build(); - - await this.assembled.simulate(); - } - - const signature = await wallet.signTransaction( - this.assembled.raw!.toXDR(), - { - networkPassphrase: this.options.networkPassphrase, - } - ); - - this.signed = TransactionBuilder.fromXDR( - signature, - this.options.networkPassphrase - ) as Tx; - - return this.server.sendTransaction(this.signed); - }, - (resp) => resp.status !== "PENDING", - secondsToWait + const signature = await this.wallet.signTransaction( + // `signAndSend` checks for `this.built` before calling `SentTransaction.init` + this.assembled.built!.toXDR(), + { + networkPassphrase: this.assembled.options.networkPassphrase, + } ); - this.sendTransactionResponse = - this.sendTransactionResponseAll[ - this.sendTransactionResponseAll.length - 1 - ]; + this.signed = TransactionBuilder.fromXDR( + signature, + this.assembled.options.networkPassphrase + ) as Tx; + + this.sendTransactionResponse = await this.server.sendTransaction(this.signed); if (this.sendTransactionResponse.status !== "PENDING") { - throw new Error( - `Tried to resubmit transaction for ${secondsToWait} seconds, but it's still failing. ` + - `All attempts: ${JSON.stringify( - this.sendTransactionResponseAll, + throw new SentTransaction.Errors.SendFailed( + 'Sending the transaction to the network failed!\n' + + JSON.stringify( + this.sendTransactionResponse, null, 2 - )}` + ) ); } @@ -605,7 +635,7 @@ class SentTransaction { this.getTransactionResponseAll = await withExponentialBackoff( () => this.server.getTransaction(hash), (resp) => resp.status === SorobanRpc.Api.GetTransactionStatus.NOT_FOUND, - secondsToWait + timeoutInSeconds ); this.getTransactionResponse = @@ -615,7 +645,7 @@ class SentTransaction { SorobanRpc.Api.GetTransactionStatus.NOT_FOUND ) { console.error( - `Waited ${secondsToWait} seconds for transaction to complete, but it did not. ` + + `Waited ${timeoutInSeconds} seconds for transaction to complete, but it did not. ` + `Returning anyway. Check the transaction status manually. ` + `Sent transaction: ${JSON.stringify( this.sendTransactionResponse, @@ -638,7 +668,7 @@ class SentTransaction { if ("getTransactionResponse" in this && this.getTransactionResponse) { // getTransactionResponse has a `returnValue` field unless it failed if ("returnValue" in this.getTransactionResponse) { - return this.options.parseResultXdr( + return this.assembled.options.parseResultXdr( this.getTransactionResponse.returnValue! ); } @@ -651,11 +681,11 @@ class SentTransaction { if (this.sendTransactionResponse) { const errorResult = this.sendTransactionResponse.errorResult?.result(); if (errorResult) { - throw new AssembledTransaction.SendFailedError( + throw new SentTransaction.Errors.SendFailed( `Transaction simulation looked correct, but attempting to send the transaction failed. Check \`simulation\` and \`sendTransactionResponseAll\` to troubleshoot. Decoded \`sendTransactionResponse.errorResultXdr\`: ${errorResult}` ); } - throw new AssembledTransaction.SendResultOnlyError( + throw new SentTransaction.Errors.SendResultOnly( `Transaction was sent to the network, but not yet awaited. No result to show. Await transaction completion with \`getTransaction(sendTransactionResponse.hash)\`` ); } @@ -668,13 +698,13 @@ class SentTransaction { } /** - * Keep calling a `fn` for `secondsToWait` seconds, if `keepWaitingIf` is true. + * Keep calling a `fn` for `timeoutInSeconds` seconds, if `keepWaitingIf` is true. * Returns an array of all attempts to call the function. */ async function withExponentialBackoff( fn: (previousFailure?: T) => Promise, keepWaitingIf: (result: T) => boolean, - secondsToWait: number, + timeoutInSeconds: number, exponentialFactor = 1.5, verbose = false ): Promise { @@ -684,7 +714,7 @@ async function withExponentialBackoff( attempts.push(await fn()); if (!keepWaitingIf(attempts[attempts.length - 1])) return attempts; - const waitUntil = new Date(Date.now() + secondsToWait * 1000).valueOf(); + const waitUntil = new Date(Date.now() + timeoutInSeconds * 1000).valueOf(); let waitTime = 1000; let totalWaitTime = waitTime; @@ -697,7 +727,7 @@ async function withExponentialBackoff( if (verbose) { console.info( `Waiting ${waitTime}ms before trying again (bringing the total wait time to ${totalWaitTime}ms so far, of total ${ - secondsToWait * 1000 + timeoutInSeconds * 1000 }ms)` ); } diff --git a/src/soroban/contract_client.ts b/src/soroban/contract_client.ts index ea1b95479..f2ea5c401 100644 --- a/src/soroban/contract_client.ts +++ b/src/soroban/contract_client.ts @@ -1,5 +1,5 @@ import { AssembledTransaction } from '.' -import { ContractSpec, xdr } from '..' +import { Account, ContractSpec, SorobanRpc, xdr } from '..' export type XDR_BASE64 = string; @@ -23,12 +23,7 @@ export interface Wallet { ) => Promise; } - -export type ContractClientOptions = { - contractId: string; - networkPassphrase: string; - rpcUrl: string; - errorTypes?: Record; +export interface AcceptsWalletOrAccount { /** * A Wallet interface, such as Freighter, that has the methods `isConnected`, `isAllowed`, `getUserInfo`, and `signTransaction`. If not provided, will attempt to import and use Freighter. Example: * @@ -42,14 +37,61 @@ export type ContractClientOptions = { * }) * ``` */ - wallet: Wallet; + wallet?: Wallet; + + /** + * You can pass in `wallet` OR `account`, but not both. If you only pass + * `wallet`, `account` will be derived from it. If you can bypass this + * behavior by passing in your own account object. + */ + account?: Account | Promise; +}; + +async function getPublicKey(wallet?: Wallet): Promise { + if (!wallet) return undefined; + if (!(await wallet.isConnected()) || !(await wallet.isAllowed())) { + return undefined; + } + return (await wallet.getUserInfo()).publicKey; +}; + +export const NULL_ACCOUNT = + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + +/** + * Get account details from the Soroban network for the publicKey currently + * selected in user's wallet. If user is not connected to their wallet, {@link + * getPublicKey} returns undefined, and this will return {@link NULL_ACCOUNT}. + * This works for simulations, which is all that's needed for most view calls. + * If you want the transaction to be included in the ledger, you will need to + * provide a connected wallet. + */ +export async function getAccount(server: SorobanRpc.Server, wallet?: Wallet): Promise { + const publicKey = await getPublicKey(wallet); + return publicKey + ? await server.getAccount(publicKey) + : new Account(NULL_ACCOUNT, "0"); +}; + +export type ContractClientOptions = AcceptsWalletOrAccount & { + contractId: string; + networkPassphrase: string; + rpcUrl: string; + errorTypes?: Record; }; export class ContractClient { + private server: SorobanRpc.Server; + constructor( public readonly spec: ContractSpec, public readonly options: ContractClientOptions, - ) {} + ) { + this.server = new SorobanRpc.Server(this.options.rpcUrl, { + allowHttp: this.options.rpcUrl.startsWith("http://"), + }); + options.account = options.account ?? getAccount(this.server, options.wallet); + } txFromJSON = (json: string): AssembledTransaction => { const { method, ...tx } = JSON.parse(json) diff --git a/test/e2e/initialize.sh b/test/e2e/initialize.sh index 2dacde9a3..fa33c3ee6 100755 --- a/test/e2e/initialize.sh +++ b/test/e2e/initialize.sh @@ -40,11 +40,8 @@ exe() { echo"${@/eval/}" ; "$@" ; } function fund_all() { exe eval "$soroban keys generate root" - exe eval "$soroban keys fund root" exe eval "$soroban keys generate alice" - exe eval "$soroban keys fund alice" exe eval "$soroban keys generate bob" - exe eval "$soroban keys fund bob" } function upload() { exe eval "($soroban contract $1 --source root --wasm $dirname/$2 --ignore-checks) > $dirname/$3" diff --git a/test/e2e/src/test-hello-world.js b/test/e2e/src/test-hello-world.js index eb60f675c..35c157f10 100644 --- a/test/e2e/src/test-hello-world.js +++ b/test/e2e/src/test-hello-world.js @@ -1,6 +1,6 @@ const test = require('ava') const fs = require('node:fs') -const { ContractSpec } = require('../../..') +const { ContractSpec, SorobanRpc } = require('../../..') const { root, wallet, rpcUrl, networkPassphrase } = require('./util') const xdr = require('../wasms/specs/test_hello_world.json') diff --git a/test/e2e/src/test-swap.js b/test/e2e/src/test-swap.js index 8e66076e2..6646c16e9 100644 --- a/test/e2e/src/test-swap.js +++ b/test/e2e/src/test-swap.js @@ -52,7 +52,7 @@ test('calling `signAndSend()` too soon throws descriptive error', async t => { min_b_for_a: amountBToSwap, }) const error = await t.throwsAsync(tx.signAndSend()) - t.true(error instanceof SorobanRpc.AssembledTransaction.NeedsMoreSignaturesError, `error is not of type 'NeedsMoreSignaturesError'; instead it is of type '${error?.constructor.name}'`) + t.true(error instanceof SorobanRpc.AssembledTransaction.Errors.NeedsMoreSignatures, `error is not of type 'NeedsMoreSignaturesError'; instead it is of type '${error?.constructor.name}'`) if (error) t.regex(error.message, /needsNonInvokerSigningBy/) }) diff --git a/test/e2e/src/util.js b/test/e2e/src/util.js index 5159633e5..b28e283ac 100644 --- a/test/e2e/src/util.js +++ b/test/e2e/src/util.js @@ -36,6 +36,7 @@ module.exports.rpcUrl = rpcUrl const networkPassphrase = process.env.SOROBAN_NETWORK_PASSPHRASE ?? "Standalone Network ; February 2017"; module.exports.networkPassphrase = networkPassphrase +// TODO: export Wallet class from soroban-sdk class Wallet { constructor(publicKey) { this.publicKey = publicKey