diff --git a/src/config/chains.ts b/src/config/chains.ts index 7531b42a98..35ccbce8b1 100644 --- a/src/config/chains.ts +++ b/src/config/chains.ts @@ -168,7 +168,7 @@ const constants = { }, }; -const ALCHEMY_WHITELISTED_DOMAINS = ["gmx.io", "app.gmx.io"]; +const ALCHEMY_WHITELISTED_DOMAINS = ["gmx.io", "app.gmx.io", "1ct-best-nonce.gmx-interface.pages.dev"]; export const RPC_PROVIDERS = { [ETH_MAINNET]: ["https://rpc.ankr.com/eth"], diff --git a/src/lib/__tests__/getBestNonce.spec.ts b/src/lib/__tests__/getBestNonce.spec.ts new file mode 100644 index 0000000000..15733abc29 --- /dev/null +++ b/src/lib/__tests__/getBestNonce.spec.ts @@ -0,0 +1,227 @@ +import { getBestNonce } from "../contracts/utils"; + +// Mocks for Wallet providers +class MockWallet { + nonce: number; + success: boolean; + timeout: number; + + constructor(nonce, success = true, timeout = 0) { + this.nonce = nonce; + this.success = success; + this.timeout = timeout; + } + + getNonce() { + return new Promise((resolve, reject) => { + setTimeout(() => { + if (this.success) { + resolve(this.nonce); + } else { + reject(new Error("Failed to get nonce")); + } + }, this.timeout); + }); + } +} + +describe("getBestNonce", () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.spyOn(console, "error").mockImplementation(jest.fn()); + }); + + test("Case 1", async () => { + const providers: any[] = [new MockWallet(1, true, 100), new MockWallet(2, true, 200), new MockWallet(3, true, 300)]; + const res = getBestNonce(providers); + jest.advanceTimersByTime(400); + expect(res).resolves.toBe(3); + }); + + test("Case 2", async () => { + const providers: any[] = [ + new MockWallet(1, true, 100), + new MockWallet(2, true, 200), + new MockWallet(3, false, 300), + ]; + const res = getBestNonce(providers); + jest.advanceTimersByTime(400); + expect(res).resolves.toBe(2); + }); + + test("Case 3", async () => { + const providers: any[] = [ + new MockWallet(1, false, 100), + new MockWallet(2, true, 200), + new MockWallet(3, false, 300), + ]; + const res = getBestNonce(providers); + jest.advanceTimersByTime(400); + expect(res).resolves.toBe(2); + }); + + test("Case 4", async () => { + const providers: any[] = [ + new MockWallet(1, false, 100), + new MockWallet(2, false, 200), + new MockWallet(3, false, 300), + ]; + const res = getBestNonce(providers); + jest.advanceTimersByTime(400); + res.catch((error) => { + expect(error).toBeDefined(); + }); + }); + + test("Case 5", async () => { + const providers: any[] = [ + new MockWallet(1, true, 100), + new MockWallet(2, true, 200), + new MockWallet(3, true, 1300), + ]; + const res = getBestNonce(providers); + jest.advanceTimersByTime(300); + await waitOneTick(); + jest.advanceTimersByTime(1200); + expect(res).resolves.toBe(2); + }); + + test("Case 6", async () => { + const providers: any[] = [ + new MockWallet(1, true, 100), + new MockWallet(2, false, 900), + new MockWallet(3, true, 1000), + ]; + const res = getBestNonce(providers); + jest.advanceTimersByTime(1100); + expect(res).resolves.toBe(3); + }); + + test("Case 7", async () => { + const providers: any[] = [ + new MockWallet(1, true, 100), + new MockWallet(2, false, 900), + new MockWallet(3, false, 1000), + ]; + const res = getBestNonce(providers); + jest.advanceTimersByTime(1100); + expect(res).resolves.toBe(1); + }); + + test("Case 8", async () => { + const providers: any[] = [ + new MockWallet(1, false, 100), + new MockWallet(2, false, 200), + new MockWallet(3, true, 4800), + ]; + const res = getBestNonce(providers); + jest.advanceTimersByTime(4900); + expect(res).resolves.toBe(3); + }); + + test("Case 9", async () => { + const providers: any[] = [ + new MockWallet(1, true, 100), + new MockWallet(2, false, 300), + new MockWallet(3, true, 1300), + ]; + const res = getBestNonce(providers); + jest.advanceTimersByTime(200); + await waitOneTick(); + jest.advanceTimersByTime(1200); + expect(res).resolves.toBe(1); + }); + + test("Case 10", async () => { + const providers: any[] = [ + new MockWallet(1, true, 4000), + new MockWallet(2, true, 5800), + new MockWallet(3, true, 6700), + ]; + const res = getBestNonce(providers); + jest.advanceTimersByTime(4100); + await waitOneTick(); + jest.advanceTimersByTime(6800); + await await expect(res).resolves.toBe(1); + }); + + test("Case 11", async () => { + const providers: any[] = [ + new MockWallet(1, true, 4900), + new MockWallet(2, true, 6100), + new MockWallet(3, true, 6200), + ]; + const res = getBestNonce(providers); + jest.advanceTimersByTime(4950); + await waitOneTick(); + jest.advanceTimersByTime(6000); + expect(res).resolves.toBe(1); + }); + + test("Case 12", async () => { + const providers: any[] = [ + new MockWallet(1, true, 6000), + new MockWallet(2, true, 7000), + new MockWallet(3, true, 8000), + ]; + const res = getBestNonce(providers); + jest.advanceTimersByTime(5100); + res.catch((error) => { + expect(error).toBeDefined(); + }); + }); + + test("Case 13", async () => { + const providers: any[] = [new MockWallet(1, true, 100)]; + const res = getBestNonce(providers); + jest.advanceTimersByTime(200); + expect(res).resolves.toBe(1); + }); + + test("Case 14", async () => { + const providers: any[] = [new MockWallet(1, true, 4900)]; + const res = getBestNonce(providers); + jest.advanceTimersByTime(4950); + await waitOneTick(); + jest.advanceTimersByTime(5000); + expect(res).resolves.toBe(1); + }); + + test("Case 15", async () => { + const providers: any[] = [new MockWallet(1, true, 100), new MockWallet(2, true, 200)]; + const res = getBestNonce(providers); + jest.advanceTimersByTime(300); + expect(res).resolves.toBe(2); + }); + + test("Case 16", async () => { + const providers: any[] = [new MockWallet(1, true, 100), new MockWallet(2, true, 1000)]; + const res = getBestNonce(providers); + jest.advanceTimersByTime(1100); + expect(res).resolves.toBe(2); + }); + + test("Case 17", async () => { + const providers: any[] = [ + new MockWallet(1, true, 100), + new MockWallet(2, true, 200), + new MockWallet(3, true, 300), + new MockWallet(4, true, 500), + ]; + const res = getBestNonce(providers); + jest.advanceTimersByTime(400); + expect(res).resolves.toBe(3); + }); + + // Clean up timers after each test + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); +}); + +async function waitOneTick() { + jest.useRealTimers(); + await new Promise((resolve) => queueMicrotask(() => resolve(null))); + jest.useFakeTimers(); +} diff --git a/src/lib/contracts/callContract.tsx b/src/lib/contracts/callContract.tsx index f9dbe48ad4..dc19d4d93a 100644 --- a/src/lib/contracts/callContract.tsx +++ b/src/lib/contracts/callContract.tsx @@ -4,9 +4,10 @@ import { getExplorerUrl } from "config/chains"; import { Contract, Wallet, Overrides } from "ethers"; import { helperToast } from "../helperToast"; import { getErrorMessage } from "./transactionErrors"; -import { getGasLimit, setGasPrice } from "./utils"; +import { getGasLimit, setGasPrice, getBestNonce } from "./utils"; import { ReactNode } from "react"; import React from "react"; +import { ARBITRUM } from "config/chains"; export async function callContract( chainId: number, @@ -46,8 +47,26 @@ export async function callContract( } if (opts.customSigners) { - // If we send the transaction to multiple RPCs simultaneously, we should specify a fixed nonce to avoid possible txn duplication. - txnOpts.nonce = await wallet.getNonce(); + const wallets: Wallet[] = []; + + // @ts-expect-error + if (!window.disableBrowserWalletRpc) { + wallets.push(wallet); + } + + // @ts-expect-error + if (!window.disablePublicRpc) { + wallets.push(opts.customSigners[0]); + } + + // @ts-expect-error + if (!window.disableFallbackRpc) { + wallets.push(opts.customSigners[1]); + } + + // If we send the transaction to multiple RPCs simultaneously, + // we should specify a fixed nonce to avoid possible txn duplication. + txnOpts.nonce = await getBestNonce(wallets); } if (opts.showPreliminaryMsg && !opts.hideSentMsg) { @@ -60,7 +79,24 @@ export async function callContract( const customSignerContracts = opts.customSigners?.map((signer) => contract.connect(signer)) || []; - const txnCalls = [contract, ...customSignerContracts].map(async (cntrct) => { + const toCall: any = []; + + // @ts-expect-error + if (!window.disableBrowserWalletRpc) { + toCall.push({ contract, caption: "Browser Wallet RPC" }); + } + + // @ts-expect-error + if (!window.disablePublicRpc) { + toCall.push({ contract: customSignerContracts[0], caption: "Public RPC" }); + } + + // @ts-expect-error + if (!window.disableFallbackRpc) { + toCall.push({ contract: customSignerContracts[1], caption: "Fallback RPC" }); + } + + const txnCalls = toCall.map(async ({ contract: cntrct, caption }) => { const txnInstance = { ...txnOpts }; txnInstance.gasLimit = opts.gasLimit ? opts.gasLimit : await getGasLimit(cntrct, method, params, opts.value); @@ -71,9 +107,18 @@ export async function callContract( await setGasPrice(txnInstance, cntrct.runner.provider, chainId); - return cntrct[method](...params, txnInstance); + return cntrct[method](...params, txnInstance).then((res) => { + if (chainId === ARBITRUM) { + // eslint-disable-next-line no-console + console.log(`Transaction sent via ${caption}`, res); + } + return res; + }); }); + // eslint-disable-next-line no-console + console.log("All RPC calls: ", txnCalls); + const res = await Promise.any(txnCalls).catch(({ errors }) => { if (errors.length > 1) { // eslint-disable-next-line no-console diff --git a/src/lib/contracts/utils.ts b/src/lib/contracts/utils.ts index e28f5a6a2e..06f1c84dfd 100644 --- a/src/lib/contracts/utils.ts +++ b/src/lib/contracts/utils.ts @@ -1,5 +1,5 @@ import { GAS_PRICE_ADJUSTMENT_MAP, MAX_GAS_PRICE_MAP } from "config/chains"; -import { Contract, BaseContract, Provider } from "ethers"; +import { Contract, BaseContract, Provider, Wallet } from "ethers"; export async function setGasPrice(txnOpts: any, provider: Provider, chainId: number) { let maxGasPrice = MAX_GAS_PRICE_MAP[chainId]; @@ -46,3 +46,61 @@ export async function getGasLimit( return (gasLimit * 11n) / 10n; // add a 10% buffer } + +export function getBestNonce(providers: Wallet[]): Promise { + const MAX_NONCE_NEEDED = 3; + const MAX_WAIT = 5000; + const ONE_MORE_WAIT = 1000; + + return new Promise(async (resolve, reject) => { + const results: number[] = []; + let resolved = false; + + const handleResolve = () => { + resolved = true; + + if (results.length) { + // eslint-disable-next-line no-console + console.log("Nonces been received: ", results); + resolve(Math.max(...results)); + } else { + reject(new Error("Failed to fetch nonce from any provider")); + } + }; + + let timerId = setTimeout(handleResolve, MAX_WAIT); + + const setResolveTimeout = (time: number) => { + clearTimeout(timerId); + + if (resolved) return; + + if (time) { + timerId = setTimeout(handleResolve, time); + } else { + handleResolve(); + } + }; + + await Promise.all( + providers.map((provider, i) => + provider + .getNonce("pending") + .then((nonce) => results.push(nonce)) + .then(() => { + if (results.length === providers.length || results.length >= MAX_NONCE_NEEDED) { + setResolveTimeout(0); + } else { + setResolveTimeout(ONE_MORE_WAIT); + } + }) + .catch((error) => { + // eslint-disable-next-line no-console + console.error(`Error fetching nonce from provider ${i}: ${error.message}`); + }) + ) + ); + + setResolveTimeout(0); + }); +}