diff --git a/src/errors.ts b/src/errors.ts index 67878c12f..9eb4d610f 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -457,3 +457,12 @@ export class ErrInvalidNetworkProviderKind extends Err { super("Invalid network provider kind. Choose between `api` and `proxy`."); } } + +/** + * Signals that the account condition was not reached + */ +export class ExpectedAccountConditionNotReachedError extends Err { + public constructor() { + super("The expected account condition was not reached."); + } +} diff --git a/src/networkProviders/accountAwaiter.dev.net.spec.ts b/src/networkProviders/accountAwaiter.dev.net.spec.ts new file mode 100644 index 000000000..3322216ee --- /dev/null +++ b/src/networkProviders/accountAwaiter.dev.net.spec.ts @@ -0,0 +1,78 @@ +import { assert } from "chai"; +import { Address } from "../address"; +import { MarkCompleted, MockNetworkProvider, Wait } from "../testutils/mockNetworkProvider"; +import { createAccountBalance } from "../testutils/utils"; +import { loadTestWallet } from "../testutils/wallets"; +import { Transaction } from "../transaction"; +import { TransactionComputer } from "../transactionComputer"; +import { AccountAwaiter } from "./accountAwaiter"; +import { AccountOnNetwork } from "./accounts"; +import { ApiNetworkProvider } from "./apiNetworkProvider"; + +describe("AccountAwaiter Tests", () => { + const provider = new MockNetworkProvider(); + + const watcher = new AccountAwaiter({ + fetcher: provider, + pollingIntervalInMilliseconds: 42, + timeoutIntervalInMilliseconds: 42 * 42, + patienceTimeInMilliseconds: 0, + }); + + it("should await on balance increase", async () => { + const alice = Address.newFromBech32("erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th"); + // alice account is created with 1000 EGLD + const initialBalance = (await provider.getAccount(alice)).balance; + + // Mock balance timeline + provider.mockAccountBalanceTimelineByAddress(alice, [ + new Wait(40), + new Wait(40), + new Wait(45), + new MarkCompleted(), + ]); + + const condition = (account: AccountOnNetwork) => { + return account.balance === initialBalance + createAccountBalance(7); + }; + + const account = await watcher.awaitOnCondition(alice, condition); + + assert.equal(account.balance, createAccountBalance(1007)); + }); + + it("should await for account balance increase on the network", async function () { + this.timeout(20000); + const alice = await loadTestWallet("alice"); + const aliceAddress = alice.getAddress(); + const frank = Address.newFromBech32("erd1kdl46yctawygtwg2k462307dmz2v55c605737dp3zkxh04sct7asqylhyv"); + + const api = new ApiNetworkProvider("https://devnet-api.multiversx.com"); + const watcher = new AccountAwaiter({ fetcher: api }); + const txComputer = new TransactionComputer(); + const value = 100_000n; + + // Create and sign the transaction + const transaction = new Transaction({ + sender: aliceAddress, + receiver: frank, + gasLimit: 50000n, + chainID: "D", + value, + }); + transaction.nonce = (await api.getAccount(aliceAddress)).nonce; + transaction.signature = await alice.signer.sign(txComputer.computeBytesForSigning(transaction)); + + const initialBalance = (await api.getAccount(frank)).balance; + + const condition = (account: AccountOnNetwork) => { + return account.balance === initialBalance + value; + }; + + await api.sendTransaction(transaction); + + const accountOnNetwork = await watcher.awaitOnCondition(frank, condition); + + assert.equal(accountOnNetwork.balance, initialBalance + value); + }); +}); diff --git a/src/networkProviders/accountAwaiter.ts b/src/networkProviders/accountAwaiter.ts new file mode 100644 index 000000000..72b27a98b --- /dev/null +++ b/src/networkProviders/accountAwaiter.ts @@ -0,0 +1,104 @@ +import { Address } from "../address"; +import { ExpectedAccountConditionNotReachedError } from "../errors"; +import { AccountOnNetwork } from "./accounts"; +import { + DEFAULT_ACCOUNT_AWAITING_PATIENCE_IN_MILLISECONDS, + DEFAULT_ACCOUNT_AWAITING_POLLING_TIMEOUT_IN_MILLISECONDS, + DEFAULT_ACCOUNT_AWAITING_TIMEOUT_IN_MILLISECONDS, +} from "./constants"; + +interface IAccountFetcher { + getAccount(address: Address): Promise; +} + +export class AccountAwaiter { + private readonly fetcher: IAccountFetcher; + private readonly pollingIntervalInMilliseconds: number; + private readonly timeoutIntervalInMilliseconds: number; + private readonly patienceTimeInMilliseconds: number; + + /** + * AccountAwaiter allows one to await until a specific event occurs on a given address. + * + * @param fetcher - Used to fetch the account of the network. + * @param pollingIntervalInMilliseconds - The polling interval, in milliseconds. + * @param timeoutIntervalInMilliseconds - The timeout, in milliseconds. + * @param patienceTimeInMilliseconds - The patience, an extra time (in milliseconds) to wait, after the account has reached its desired condition. + */ + constructor(options: { + fetcher: IAccountFetcher; + pollingIntervalInMilliseconds?: number; + timeoutIntervalInMilliseconds?: number; + patienceTimeInMilliseconds?: number; + }) { + this.fetcher = options.fetcher; + + this.pollingIntervalInMilliseconds = + options.pollingIntervalInMilliseconds ?? DEFAULT_ACCOUNT_AWAITING_POLLING_TIMEOUT_IN_MILLISECONDS; + + this.timeoutIntervalInMilliseconds = + options.timeoutIntervalInMilliseconds ?? DEFAULT_ACCOUNT_AWAITING_TIMEOUT_IN_MILLISECONDS; + + this.patienceTimeInMilliseconds = + options.patienceTimeInMilliseconds ?? DEFAULT_ACCOUNT_AWAITING_PATIENCE_IN_MILLISECONDS; + } + + /** + * Waits until the condition is satisfied. + * + * @param address - The address to monitor. + * @param condition - A callable that evaluates the desired condition. + */ + async awaitOnCondition( + address: Address, + condition: (account: AccountOnNetwork) => boolean, + ): Promise { + const doFetch = async () => await this.fetcher.getAccount(address); + + return this.awaitConditionally(condition, doFetch, new ExpectedAccountConditionNotReachedError()); + } + + private async awaitConditionally( + isSatisfied: (account: AccountOnNetwork) => boolean, + doFetch: () => Promise, + error: Error, + ): Promise { + let isConditionSatisfied = false; + let fetchedData: AccountOnNetwork | null = null; + + const maxNumberOfRetries = Math.floor(this.timeoutIntervalInMilliseconds / this.pollingIntervalInMilliseconds); + + let numberOfRetries = 0; + + while (numberOfRetries < maxNumberOfRetries) { + try { + fetchedData = await doFetch(); + isConditionSatisfied = isSatisfied(fetchedData); + + if (isConditionSatisfied) { + break; + } + } catch (ex) { + throw ex; + } + + numberOfRetries += 1; + await this._sleep(this.pollingIntervalInMilliseconds); + } + + if (!fetchedData || !isConditionSatisfied) { + throw error; + } + + if (this.patienceTimeInMilliseconds) { + await this._sleep(this.patienceTimeInMilliseconds); + return doFetch(); + } + + return fetchedData; + } + + private async _sleep(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); + } +} diff --git a/src/networkProviders/apiNetworkProvider.ts b/src/networkProviders/apiNetworkProvider.ts index fff408c6b..e7030d8c2 100644 --- a/src/networkProviders/apiNetworkProvider.ts +++ b/src/networkProviders/apiNetworkProvider.ts @@ -8,6 +8,7 @@ import { prepareTransactionForBroadcasting, TransactionOnNetwork } from "../tran import { TransactionStatus } from "../transactionStatus"; import { TransactionWatcher } from "../transactionWatcher"; import { getAxios } from "../utils"; +import { AccountAwaiter } from "./accountAwaiter"; import { AccountOnNetwork } from "./accounts"; import { defaultAxiosConfig, defaultPagination } from "./config"; import { BaseUserAgent } from "./constants"; @@ -88,15 +89,21 @@ export class ApiNetworkProvider implements INetworkProvider { return account; } - awaitAccountOnCondition( - _address: Address, - _condition: (account: AccountOnNetwork) => boolean, + async awaitAccountOnCondition( + address: Address, + condition: (account: AccountOnNetwork) => boolean, options?: AwaitingOptions, - ): AccountOnNetwork { + ): Promise { if (!options) { options = new AwaitingOptions(); } - throw new Error("Method not implemented."); + const awaiter = new AccountAwaiter({ + fetcher: this, + patienceTimeInMilliseconds: options.patienceInMilliseconds, + pollingIntervalInMilliseconds: options.pollingIntervalInMilliseconds, + timeoutIntervalInMilliseconds: options.timeoutInMilliseconds, + }); + return await awaiter.awaitOnCondition(address, condition); } async sendTransaction(tx: Transaction): Promise { diff --git a/src/networkProviders/interface.ts b/src/networkProviders/interface.ts index 53de8f5d3..8290c8bb3 100644 --- a/src/networkProviders/interface.ts +++ b/src/networkProviders/interface.ts @@ -54,7 +54,7 @@ export interface INetworkProvider { address: Address, condition: (account: AccountOnNetwork) => boolean, options?: AwaitingOptions, - ): AccountOnNetwork; + ): Promise; /** * Broadcasts an already-signed transaction. diff --git a/src/networkProviders/proxyNetworkProvider.dev.net.spec.ts b/src/networkProviders/proxyNetworkProvider.dev.net.spec.ts index 6333a757b..07ac78785 100644 --- a/src/networkProviders/proxyNetworkProvider.dev.net.spec.ts +++ b/src/networkProviders/proxyNetworkProvider.dev.net.spec.ts @@ -349,7 +349,7 @@ describe("ProxyNetworkProvider Tests", function () { }); it("should send and await for completed transaction", async function () { - this.timeout(40000); + this.timeout(50000); const bob = await loadTestWallet("bob"); const transactionComputer = new TransactionComputer(); let transaction = new Transaction({ diff --git a/src/networkProviders/proxyNetworkProvider.ts b/src/networkProviders/proxyNetworkProvider.ts index 47657fe02..ed454ca0a 100644 --- a/src/networkProviders/proxyNetworkProvider.ts +++ b/src/networkProviders/proxyNetworkProvider.ts @@ -8,6 +8,7 @@ import { prepareTransactionForBroadcasting, TransactionOnNetwork } from "../tran import { TransactionStatus } from "../transactionStatus"; import { TransactionWatcher } from "../transactionWatcher"; import { getAxios } from "../utils"; +import { AccountAwaiter } from "./accountAwaiter"; import { AccountOnNetwork, GuardianData } from "./accounts"; import { defaultAxiosConfig } from "./config"; import { BaseUserAgent } from "./constants"; @@ -94,15 +95,21 @@ export class ProxyNetworkProvider implements INetworkProvider { return account; } - awaitAccountOnCondition( - _address: Address, - _condition: (account: AccountOnNetwork) => boolean, + async awaitAccountOnCondition( + address: Address, + condition: (account: AccountOnNetwork) => boolean, options?: AwaitingOptions, - ): AccountOnNetwork { + ): Promise { if (!options) { options = new AwaitingOptions(); } - throw new Error("Method not implemented."); + const awaiter = new AccountAwaiter({ + fetcher: this, + patienceTimeInMilliseconds: options.patienceInMilliseconds, + pollingIntervalInMilliseconds: options.pollingIntervalInMilliseconds, + timeoutIntervalInMilliseconds: options.timeoutInMilliseconds, + }); + return await awaiter.awaitOnCondition(address, condition); } async sendTransaction(tx: Transaction): Promise { diff --git a/src/testutils/mockNetworkProvider.ts b/src/testutils/mockNetworkProvider.ts index 9b9aa819b..badc181ab 100644 --- a/src/testutils/mockNetworkProvider.ts +++ b/src/testutils/mockNetworkProvider.ts @@ -77,7 +77,7 @@ export class MockNetworkProvider implements INetworkProvider { _address: Address, _condition: (account: AccountOnNetwork) => boolean, _options?: AwaitingOptions, - ): AccountOnNetwork { + ): Promise { throw new Error("Method not implemented."); } estimateTransactionCost(_tx: Transaction): Promise { @@ -159,6 +159,27 @@ export class MockNetworkProvider implements INetworkProvider { this.getTransactionResponders.unshift(new GetTransactionResponder(predicate, response)); } + mockAccountBalanceTimelineByAddress(address: Address, timelinePoints: Array): void { + const executeTimeline = async () => { + for (const point of timelinePoints) { + if (point instanceof MarkCompleted) { + // Mark account condition as reached + this.mockUpdateAccount(address, (account) => { + account.balance += createAccountBalance(7); + }); + } else if (point instanceof Wait) { + // Wait for the specified time + await this.sleep(point.milliseconds); + } + } + }; + + // Start the timeline execution in a separate async "thread" + executeTimeline().catch((err) => { + console.error("Error executing timeline:", err); + }); + } + async mockTransactionTimeline(transaction: Transaction, timelinePoints: any[]): Promise { return this.mockTransactionTimelineByHash(transaction.getHash(), timelinePoints); } @@ -187,6 +208,10 @@ export class MockNetworkProvider implements INetworkProvider { } } + private sleep(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); + } + async getAccount(address: Address): Promise { let account = this.accounts.get(address.toBech32()); if (account) {