diff --git a/cmd/crates/soroban-spec-typescript/src/project_template/src/invoke.ts b/cmd/crates/soroban-spec-typescript/src/project_template/src/invoke.ts index 5d9d60d2ae..137f60673e 100644 --- a/cmd/crates/soroban-spec-typescript/src/project_template/src/invoke.ts +++ b/cmd/crates/soroban-spec-typescript/src/project_template/src/invoke.ts @@ -63,10 +63,10 @@ export async function invoke( ? { txUnsigned: XDR_BASE64, simulation: Simulation } : R extends "full" // might be a read/view call, which means `txSigned`, `sendTx` and `getTx` will all be unnecessary - ? { txUnsigned: XDR_BASE64, simulation: Simulation, txSigned?: XDR_BASE64, sendTransactionResponse?: SendTx, getTransactionResponse?: GetTx, getTransactionResponseAll?: GetTx[] } + ? { txUnsigned: XDR_BASE64, simulation: Simulation, txSigned?: XDR_BASE64, sendTransactionResponseAll?: SendTx[], sendTransactionResponse?: SendTx, getTransactionResponse?: GetTx, getTransactionResponseAll?: GetTx[] } : R extends undefined - ? { txUnsigned: XDR_BASE64, simulation: Simulation, txSigned?: XDR_BASE64, sendTransactionResponse?: SendTx, getTransactionResponse?: GetTx, getTransactionResponseAll?: GetTx[], result: T } - : { txUnsigned: XDR_BASE64, simulation: Simulation, txSigned?: XDR_BASE64, sendTransactionResponse?: SendTx, getTransactionResponse?: GetTx, getTransactionResponseAll?: GetTx[], result: T } + ? { txUnsigned: XDR_BASE64, simulation: Simulation, txSigned?: XDR_BASE64, sendTransactionResponseAll?: SendTx[], sendTransactionResponse?: SendTx, getTransactionResponse?: GetTx, getTransactionResponseAll?: GetTx[], result: T } + : { txUnsigned: XDR_BASE64, simulation: Simulation, txSigned?: XDR_BASE64, sendTransactionResponseAll?: SendTx[], sendTransactionResponse?: SendTx, getTransactionResponse?: GetTx, getTransactionResponseAll?: GetTx[], result: T } >; export async function invoke({ method, @@ -212,11 +212,68 @@ export async function signTx( ) as Tx; } +/** + * Keep calling a `fn` for `secondsToWait` seconds, if `keepWaitingIf` is true. + * Returns an array of all attempts to call the function. + */ +async function withExponentialBackoff( + fn: () => Promise, + keepWaitingIf: (result: T) => boolean, + secondsToWait: number, + exponentialFactor = 1.5, + verbose = false, +): Promise { + const attempts: T[] = [] + + let count = 0 + attempts.push(await fn()) + if (!keepWaitingIf(attempts[attempts.length - 1])) return attempts + + const waitUntil = new Date(Date.now() + secondsToWait * 1000).valueOf() + let waitTime = 1000 + let totalWaitTime = waitTime + + while (Date.now() < waitUntil && keepWaitingIf(attempts[attempts.length - 1])) { + count++ + // Wait a beat + if (verbose) { + console.log(`Waiting ${waitTime}ms before trying again (bringing the total wait time to ${totalWaitTime}ms so far, of total ${secondsToWait * 1000}ms)`) + } + await new Promise(res => setTimeout(res, waitTime)) + // Exponential backoff + waitTime = waitTime * exponentialFactor; + if (new Date(Date.now() + waitTime).valueOf() > waitUntil) { + waitTime = waitUntil - Date.now() + if (verbose) { + console.log(`was gonna wait too long; new waitTime: ${waitTime}ms`) + } + } + totalWaitTime = waitTime + totalWaitTime + // Try again + attempts.push(await fn()) + if (verbose && keepWaitingIf(attempts[attempts.length - 1])) { + console.log( + `${count}. Called ${fn}; ${ + attempts.length + } prev attempts. Most recent: ${ + JSON.stringify(attempts[attempts.length - 1], null, 2) + }` + ) + } + } + + return attempts +} + /** * Send a transaction to the Soroban network. * - * Wait `secondsToWait` seconds for the transaction to complete (default: 10). - * If you pass `0`, it will automatically return the `sendTransaction` results, + * Wait `secondsToWait` seconds (default: 10) for both: + * + * - the transaction to SEND successfully. Will keep trying if the server returns `TRY_AGAIN_LATER`. + * - the transaction to COMPLETE. Will keep checking if the server returns `PENDING`. + * + * If you pass `secondsToWait: 0`, it will automatically return the first `sendTransaction` results, * rather than using `getTransaction`. * * If you need to construct or sign a transaction yourself rather than using @@ -228,62 +285,33 @@ export async function sendTx( tx: Tx, secondsToWait: number, server: SorobanClient.Server -): Promise<{ sendTransactionResponse: SendTx, getTransactionResponse?: GetTx, getTransactionResponseAll?: GetTx[] }> { - let sendTransactionResponse = await server.sendTransaction(tx); +): Promise<{ sendTransactionResponseAll: SendTx[], sendTransactionResponse: SendTx, getTransactionResponse?: GetTx, getTransactionResponseAll?: GetTx[] }> { + const sendTransactionResponseAll = await withExponentialBackoff( + () => server.sendTransaction(tx), + resp => resp.status !== "PENDING", + secondsToWait + ) - let waitUntil = new Date(Date.now() + secondsToWait * 1000).valueOf(); - let waitTime = 1000; - let exponentialFactor = 1.5; + const sendTransactionResponse = sendTransactionResponseAll[sendTransactionResponseAll.length - 1] - while ( - Date.now() < waitUntil && - sendTransactionResponse.status === "TRY_AGAIN_LATER" - ) { - // Wait a beat - await new Promise((resolve) => setTimeout(resolve, waitTime)); - // Exponential backoff - waitTime = waitTime * exponentialFactor; - // try submitting again - sendTransactionResponse = await server.sendTransaction(tx) - } - - if (sendTransactionResponse.status === "TRY_AGAIN_LATER") { + if (sendTransactionResponse.status !== "PENDING") { throw new Error( `Tried to resubmit transaction for ${ secondsToWait - } seconds, but RPC still says to try again later. ` + - `Info: ${JSON.stringify( - sendTransactionResponse, + } seconds, but it's still failing. ` + + `All attempts: ${JSON.stringify( + sendTransactionResponseAll, null, 2 )}` ); } - if (sendTransactionResponse.status !== "PENDING" || secondsToWait === 0) { - return { sendTransactionResponse }; - } - - const getTransactionResponseAll: GetTx[] = []; - getTransactionResponseAll.push(await server.getTransaction( - sendTransactionResponse.hash - )); - - waitUntil = new Date(Date.now() + secondsToWait * 1000).valueOf(); - - while ( - Date.now() < waitUntil && - getTransactionResponseAll[getTransactionResponseAll.length - 1].status === SorobanRpc.GetTransactionStatus.NOT_FOUND - ) { - // Wait a beat - await new Promise((resolve) => setTimeout(resolve, waitTime)); - // Exponential backoff - waitTime = waitTime * exponentialFactor; - // See if the transaction is complete - getTransactionResponseAll.push(await server.getTransaction( - sendTransactionResponse.hash - )); - } + const getTransactionResponseAll = await withExponentialBackoff( + () => server.getTransaction(sendTransactionResponse.hash), + resp => resp.status === SorobanRpc.GetTransactionStatus.NOT_FOUND, + secondsToWait + ) if (getTransactionResponseAll[getTransactionResponseAll.length - 1].status === SorobanRpc.GetTransactionStatus.NOT_FOUND) { console.error( @@ -291,15 +319,21 @@ export async function sendTx( secondsToWait } seconds for transaction to complete, but it did not. ` + `Returning anyway. Check the transaction status manually. ` + - `Info: ${JSON.stringify( + `Sent transaction: ${JSON.stringify( sendTransactionResponse, null, 2 + )}\n` + + `All attempts to get the result: ${JSON.stringify( + getTransactionResponseAll, + null, + 2 )}` ); } return { + sendTransactionResponseAll, sendTransactionResponse, getTransactionResponseAll, getTransactionResponse: getTransactionResponseAll[getTransactionResponseAll.length - 1] diff --git a/cmd/crates/soroban-spec-typescript/ts-tests/src/test-swap.ts b/cmd/crates/soroban-spec-typescript/ts-tests/src/test-swap.ts index 47538269e3..c3b93e303c 100644 --- a/cmd/crates/soroban-spec-typescript/ts-tests/src/test-swap.ts +++ b/cmd/crates/soroban-spec-typescript/ts-tests/src/test-swap.ts @@ -39,13 +39,11 @@ const server = new Server(rpcUrl, { allowHttp: rpcUrl.startsWith("http://"), }) +const amountAToSwap = 10n +const amountBToSwap = 1n + test('root swaps alice 10 A for 1 B', async t => { - // ensure some starting balance for each token - await Promise.all([ - await tokenA.mint({ to: root.keypair.publicKey(), amount: 100n }), - await tokenB.mint({ to: alice.keypair.publicKey(), amount: 100n }), - ]) - const [ + let [ rootStartingABalance, rootStartingBBalance, aliceStartingABalance, @@ -56,12 +54,28 @@ test('root swaps alice 10 A for 1 B', async t => { (await tokenA.balance({ id: alice.keypair.publicKey() })).result, (await tokenB.balance({ id: alice.keypair.publicKey() })).result, ]) - - t.true(rootStartingABalance >= 100n, `minting root some A failed! rootStartingABalance: ${rootStartingABalance}`) - t.true(aliceStartingBBalance >= 100n, `minting alice some B failed! aliceStartingBBalance: ${aliceStartingBBalance}`) - - const amountAToSwap = 10n - const amountBToSwap = 1n + if (rootStartingABalance < amountAToSwap || aliceStartingBBalance < amountBToSwap) { + // ensure some starting balance for each token + await Promise.all([ + await tokenA.mint({ to: root.keypair.publicKey(), amount: 100n }), + await tokenB.mint({ to: alice.keypair.publicKey(), amount: 100n }), + ]) + + ;[ + rootStartingABalance, + rootStartingBBalance, + aliceStartingABalance, + aliceStartingBBalance, + ] = await Promise.all([ + (await tokenA.balance({ id: root.keypair.publicKey() }) ).result, + (await tokenB.balance({ id: root.keypair.publicKey() }) ).result, + (await tokenA.balance({ id: alice.keypair.publicKey() })).result, + (await tokenB.balance({ id: alice.keypair.publicKey() })).result, + ]) + + t.true(rootStartingABalance >= amountAToSwap, `minting root some A failed! rootStartingABalance: ${rootStartingABalance}`) + t.true(aliceStartingBBalance >= amountBToSwap, `minting alice some B failed! aliceStartingBBalance: ${aliceStartingBBalance}`) + } let { txUnsigned } = await swap.swap({ a: root.keypair.publicKey(),