Skip to content

Commit

Permalink
feat: retry sendTx, same as getTx
Browse files Browse the repository at this point in the history
and only mint tokens if we need them

there's still sequence number problems when running all tests in
parallel, but the refactoring to improve multi-auth might show the way
forward
  • Loading branch information
chadoh committed Oct 24, 2023
1 parent c671e50 commit f448483
Show file tree
Hide file tree
Showing 2 changed files with 111 additions and 63 deletions.
136 changes: 85 additions & 51 deletions cmd/crates/soroban-spec-typescript/src/project_template/src/invoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,10 @@ export async function invoke<R extends ResponseTypes = undefined, T = string>(
? { 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<R extends ResponseTypes, T = string>({
method,
Expand Down Expand Up @@ -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<T>(
fn: () => Promise<T>,
keepWaitingIf: (result: T) => boolean,
secondsToWait: number,
exponentialFactor = 1.5,
verbose = false,
): Promise<T[]> {
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
Expand All @@ -228,78 +285,55 @@ 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(
`Waited ${
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]
Expand Down
38 changes: 26 additions & 12 deletions cmd/crates/soroban-spec-typescript/ts-tests/src/test-swap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(),
Expand Down

0 comments on commit f448483

Please sign in to comment.