diff --git a/src.ts/_tests/test-providers-fallback.ts b/src.ts/_tests/test-providers-fallback.ts new file mode 100644 index 0000000000..4b27165058 --- /dev/null +++ b/src.ts/_tests/test-providers-fallback.ts @@ -0,0 +1,95 @@ +import assert from "assert"; + +import { + isError, makeError, + + AbstractProvider, FallbackProvider, Network, +} from "../index.js"; + +import type { + PerformActionRequest +} from "../index.js"; + + + +const network = Network.from("mainnet"); + +function stall(duration: number): Promise { + return new Promise((resolve) => { setTimeout(resolve, duration); }); +} + + +export type Performer = (req: PerformActionRequest) => Promise; + +export class MockProvider extends AbstractProvider { + readonly _perform: Performer; + + constructor(perform: Performer) { + super(network, { cacheTimeout: -1 }); + this._perform = perform; + } + + async _detectNetwork(): Promise { return network; } + + async perform(req: PerformActionRequest): Promise { + return await this._perform(req); + } +} + +describe("Test Fallback broadcast", function() { + + const txHash = "0x33017397ef7c7943dee3b422aec52b0a210de58d73d49c1b3ce455970f01c83a"; + + async function test(actions: Array<{ timeout: number, error?: Error }>): Promise { + // https://sepolia.etherscan.io/tx/0x33017397ef7c7943dee3b422aec52b0a210de58d73d49c1b3ce455970f01c83a + const tx = "0x02f87683aa36a7048459682f00845d899ef982520894b5bdaa442bb34f27e793861c456cd5bdc527ac8c89056bc75e2d6310000080c001a07503893743e94445b2361a444343757e6f59d52e19e9b3f65eb138d802eaa972a06e4e9bc10ff55474f9aac0a4c284733b4195cb7b273de5e7465ce75a168e0c38"; + + const providers: Array = actions.map(({ timeout, error }) => { + return new MockProvider(async (r) => { + if (r.method === "getBlockNumber") { return 1; } + if (r.method === "broadcastTransaction") { + await stall(timeout); + if (error) { throw error; } + return txHash; + } + throw new Error(`unhandled method: ${ r.method }`); + }); + });; + + const provider = new FallbackProvider(providers); + return await provider.broadcastTransaction(tx); + } + + it("picks late non-failed broadcasts", async function() { + const result = await test([ + { timeout: 200, error: makeError("already seen", "UNKNOWN_ERROR") }, + { timeout: 4000, error: makeError("already seen", "UNKNOWN_ERROR") }, + { timeout: 400 }, + ]); + assert(result.hash === txHash, "result.hash === txHash"); + }); + + it("picks late non-failed broadcasts with quorum-met red-herrings", async function() { + const result = await test([ + { timeout: 200, error: makeError("bad nonce", "NONCE_EXPIRED") }, + { timeout: 400, error: makeError("bad nonce", "NONCE_EXPIRED") }, + { timeout: 1000 }, + ]); + assert(result.hash === txHash, "result.hash === txHash"); + }); + + it("insufficient funds short-circuit broadcast", async function() { + await assert.rejects(async function() { + const result = await test([ + { timeout: 200, error: makeError("is broke", "INSUFFICIENT_FUNDS") }, + { timeout: 400, error: makeError("is broke", "INSUFFICIENT_FUNDS") }, + { timeout: 800 }, + { timeout: 1000 }, + ]); + console.log(result); + }, function(error: unknown) { + assert(isError(error, "INSUFFICIENT_FUNDS")); + return true; + }); + }); +}); diff --git a/src.ts/providers/provider-fallback.ts b/src.ts/providers/provider-fallback.ts index f4952f4de7..7f8fc6eef8 100644 --- a/src.ts/providers/provider-fallback.ts +++ b/src.ts/providers/provider-fallback.ts @@ -5,7 +5,7 @@ * @_section: api/providers/fallback-provider:Fallback Provider [about-fallback-provider] */ import { - getBigInt, getNumber, assert, assertArgument + assert, assertArgument, getBigInt, getNumber, isError } from "../utils/index.js"; import { AbstractProvider } from "./abstract-provider.js"; @@ -707,16 +707,46 @@ export class FallbackProvider extends AbstractProvider { // a cost on the user, so spamming is safe-ish. Just send it to // every backend. if (req.method === "broadcastTransaction") { - const results = await Promise.all(this.#configs.map(async ({ provider, weight }) => { + // Once any broadcast provides a positive result, use it. No + // need to wait for anyone else + const results: Array = this.#configs.map((c) => null); + const broadcasts = this.#configs.map(async ({ provider, weight }, index) => { try { const result = await provider._perform(req); - return Object.assign(normalizeResult({ result }), { weight }); + results[index] = Object.assign(normalizeResult({ result }), { weight }); } catch (error: any) { - return Object.assign(normalizeResult({ error }), { weight }); + results[index] = Object.assign(normalizeResult({ error }), { weight }); } - })); + }); + + // As each promise finishes... + while (true) { + // Check for a valid broadcast result + const done = >results.filter((r) => (r != null)); + for (const { value } of done) { + if (!(value instanceof Error)) { return value; } + } + + // Check for a legit broadcast error (one which we cannot + // recover from; some nodes may return the following red + // herring events: + // - alredy seend (UNKNOWN_ERROR) + // - NONCE_EXPIRED + // - REPLACEMENT_UNDERPRICED + const result = checkQuorum(this.quorum, >results.filter((r) => (r != null))); + if (isError(result, "INSUFFICIENT_FUNDS")) { + throw result; + } + + // Kick off the next provider (if any) + const waiting = broadcasts.filter((b, i) => (results[i] == null)); + if (waiting.length === 0) { break; } + await Promise.race(waiting); + } - const result = getAnyResult(this.quorum, results); + // Use standard quorum results; any result was returned above, + // so this will find any error that met quorum if any + const result = getAnyResult(this.quorum, >results); assert(result !== undefined, "problem multi-broadcasting", "SERVER_ERROR", { request: "%sub-requests", info: { request: req, results: results.map(stringify) }