diff --git a/apps/submitter/lib/handlers/createAccount/computeHappyAccountAddress.ts b/apps/submitter/lib/handlers/createAccount/computeHappyAccountAddress.ts index a0d8e00374..a2185db963 100644 --- a/apps/submitter/lib/handlers/createAccount/computeHappyAccountAddress.ts +++ b/apps/submitter/lib/handlers/createAccount/computeHappyAccountAddress.ts @@ -9,7 +9,7 @@ import { } from "viem" import { encodePacked, parseAbi } from "viem/utils" import { abis, deployment } from "#lib/env" -import { publicClient } from "#lib/utils/clients" +import { publicClient } from "#lib/services" import { logger } from "#lib/utils/logger" const initializeAbi = parseAbi(["function initialize(address owner)"]) diff --git a/apps/submitter/lib/handlers/createAccount/createAccount.ts b/apps/submitter/lib/handlers/createAccount/createAccount.ts index 77288165f3..4768e6a912 100644 --- a/apps/submitter/lib/handlers/createAccount/createAccount.ts +++ b/apps/submitter/lib/handlers/createAccount/createAccount.ts @@ -1,19 +1,17 @@ import { type Address, type Optional, assertDef } from "@happy.tech/common" -import { createWalletClient } from "viem" import { abis, deployment, env } from "#lib/env" import { outputForGenericError } from "#lib/handlers/errors" -import { evmNonceManager, evmReceiptService, replaceTransaction } from "#lib/services" +import { evmNonceManager, evmReceiptService, publicClient, replaceTransaction, walletClient } from "#lib/services" import { accountDeployer } from "#lib/services/evmAccounts" import { traceFunction } from "#lib/telemetry/traces" import { type EvmTxInfo, SubmitterError } from "#lib/types" -import { config, isNonceTooLowError, publicClient } from "#lib/utils/clients" import { getFees } from "#lib/utils/gas" import { logger } from "#lib/utils/logger" import { decodeEvent } from "#lib/utils/parsing" +import { isNonceTooLowError } from "#lib/utils/viem" import { computeHappyAccountAddress } from "./computeHappyAccountAddress" import { CreateAccount, type CreateAccountInput, type CreateAccountOutput } from "./types" -const walletClient = createWalletClient({ ...config, account: accountDeployer }) const WaitForReceiptError = Symbol("WaitForReceiptError") async function createAccount({ salt, owner }: CreateAccountInput): Promise { diff --git a/apps/submitter/lib/handlers/execute/execute.spec.ts b/apps/submitter/lib/handlers/execute/execute.spec.ts index d0de027074..aa190f9116 100644 --- a/apps/submitter/lib/handlers/execute/execute.spec.ts +++ b/apps/submitter/lib/handlers/execute/execute.spec.ts @@ -10,9 +10,9 @@ import { env } from "#lib/env" import type { ExecuteError, ExecuteSuccess } from "#lib/handlers/execute" import type { SimulateError } from "#lib/handlers/simulate" import { blockService, boopReceiptService } from "#lib/services" +import { chain } from "#lib/services/clients" import { type Boop, Onchain, SubmitterError } from "#lib/types" import { computeBoopHash } from "#lib/utils/boop" -import { config } from "#lib/utils/clients" import { apiClient, assertMintLog, @@ -23,10 +23,11 @@ import { getNonce, mockDeployments, signBoop, + transport, withInterval, } from "#lib/utils/test" -const testClient = createTestClient({ ...config, mode: "anvil" }) +const testClient = createTestClient({ chain, transport, mode: "anvil" }) const testAccount = privateKeyToAccount(generatePrivateKey()) const sign = async (tx: Boop) => await signBoop(testAccount, tx) diff --git a/apps/submitter/lib/handlers/simulate/simulate.ts b/apps/submitter/lib/handlers/simulate/simulate.ts index a6d647ad48..17843d37c3 100644 --- a/apps/submitter/lib/handlers/simulate/simulate.ts +++ b/apps/submitter/lib/handlers/simulate/simulate.ts @@ -5,11 +5,10 @@ import { abis, deployment, env } from "#lib/env" import { outputForExecuteError, outputForGenericError, outputForRevertError } from "#lib/handlers/errors" import { notePossibleMisbehaviour } from "#lib/policies/misbehaviour" import { getSubmitterFee, validateSubmitterFee } from "#lib/policies/submitterFee" -import { computeHash, simulationCache } from "#lib/services" +import { computeHash, publicClient, simulationCache } from "#lib/services" import { traceFunction } from "#lib/telemetry/traces" import { type Boop, CallStatus, Onchain, type OnchainStatus, SubmitterError } from "#lib/types" import { encodeBoop } from "#lib/utils/boop" -import { publicClient } from "#lib/utils/clients" import { getFees } from "#lib/utils/gas" import { logger } from "#lib/utils/logger" import { getRevertError } from "#lib/utils/parsing" diff --git a/apps/submitter/lib/handlers/submit/submit.spec.ts b/apps/submitter/lib/handlers/submit/submit.spec.ts index 31c2f70607..8682272264 100644 --- a/apps/submitter/lib/handlers/submit/submit.spec.ts +++ b/apps/submitter/lib/handlers/submit/submit.spec.ts @@ -8,8 +8,15 @@ import { generatePrivateKey, privateKeyToAccount } from "viem/accounts" import { env } from "#lib/env" import type { SubmitError, SubmitSuccess } from "#lib/handlers/submit" import { type Boop, type BoopReceipt, Onchain, SubmitterError } from "#lib/types" -import { publicClient } from "#lib/utils/clients" -import { apiClient, assertMintLog, createMintBoop, createSmartAccount, getNonce, signBoop } from "#lib/utils/test" +import { + apiClient, + assertMintLog, + createMintBoop, + createSmartAccount, + getNonce, + publicClient, + signBoop, +} from "#lib/utils/test" const testAccount = privateKeyToAccount(generatePrivateKey()) const sign = (tx: Boop) => signBoop(testAccount, tx) diff --git a/apps/submitter/lib/handlers/submit/submit.ts b/apps/submitter/lib/handlers/submit/submit.ts index 5a2614ee76..6621a11897 100644 --- a/apps/submitter/lib/handlers/submit/submit.ts +++ b/apps/submitter/lib/handlers/submit/submit.ts @@ -12,14 +12,15 @@ import { computeHash, evmNonceManager, findExecutionAccount, + walletClient, } from "#lib/services" import { accountDeployer } from "#lib/services/evmAccounts" import { traceFunction } from "#lib/telemetry/traces" import { type Boop, type EvmTxInfo, Onchain, SubmitterError } from "#lib/types" import { encodeBoop, updateBoopFromSimulation } from "#lib/utils/boop" -import { isNonceTooLowError, walletClient } from "#lib/utils/clients" import { getFees } from "#lib/utils/gas" import { logger } from "#lib/utils/logger" +import { isNonceTooLowError } from "#lib/utils/viem" import type { SubmitError, SubmitInput, SubmitOutput, SubmitSuccess } from "./types" async function submit(input: SubmitInput): Promise { diff --git a/apps/submitter/lib/server/accountRoute.spec.ts b/apps/submitter/lib/server/accountRoute.spec.ts index e20eb2128c..533383e5ec 100644 --- a/apps/submitter/lib/server/accountRoute.spec.ts +++ b/apps/submitter/lib/server/accountRoute.spec.ts @@ -5,8 +5,7 @@ import { describe, expect, it } from "bun:test" import { generatePrivateKey, privateKeyToAccount } from "viem/accounts" import { abis, deployment } from "#lib/env" import { computeHappyAccountAddress } from "#lib/handlers/createAccount/computeHappyAccountAddress" -import { publicClient } from "#lib/utils/clients" -import { createSmartAccount } from "#lib/utils/test" +import { createSmartAccount, publicClient } from "#lib/utils/test" const testAccount = privateKeyToAccount(generatePrivateKey()) describe("routes: api/accounts", () => { diff --git a/apps/submitter/lib/services/BlockService.ts b/apps/submitter/lib/services/BlockService.ts index 7cd0a5e3c8..8a37fbb494 100644 --- a/apps/submitter/lib/services/BlockService.ts +++ b/apps/submitter/lib/services/BlockService.ts @@ -18,9 +18,10 @@ import { AlertType } from "#lib/policies/alerting" import { currentBlockGauge } from "#lib/telemetry/metrics" import { LruCache } from "#lib/utils/LruCache" import { recoverAlert, sendAlert } from "#lib/utils/alert" -import { chain, publicClient, rpcUrls, stringify } from "#lib/utils/clients" import { blockLogger } from "#lib/utils/logger" import { Bytes } from "#lib/utils/validation/ark" +import { stringify } from "#lib/utils/viem" +import { chain, publicClient, rpcUrls } from "./clients" /** * Type of block we get from Viem's `getBlock` — made extra permissive for safety, @@ -65,8 +66,6 @@ const TIMEOUT_MSG = "Timed out while waiting for block" export class BlockService { #current?: Block #previous?: Block - #client!: PublicClient - #backfillMutex = new Mutex() #callbacks: Set<(block: Block) => void | Promise> = new Set() /** Zero-index attempt number for the current client. */ @@ -75,6 +74,9 @@ export class BlockService { /** Current RPC URL (a value from {@link rpcUrls}) */ #rpcUrl = "" + /** RPC URls besides {@link #rpcUrl} that are live and keeping up with blocks, as of the latest RPC selection. */ + #otherLiveRpcUrls: string[] = rpcUrls + /** Set of RPCs that failed in the last minute, we will prioritize selecting a RPC not in this set if possible. */ #recentlyFailedRpcs = new Set() @@ -117,6 +119,14 @@ export class BlockService { } } + getRpcUrl(): string { + return this.#rpcUrl + } + + getOtherLiveRpcUrls(): string[] { + return this.#otherLiveRpcUrls + } + getCurrentBlock(): Block { if (!this.#current) throw Error("BlockService not initialized!") return this.#current @@ -137,7 +147,7 @@ export class BlockService { // RPC SELECTION /** - * Select a new RPC service and sets {@link this.#client} to a client for it. + * Select a new RPC service and sets {@link this.#rpcUrl}. * * Sketch of the process: * - Ping all RPCs for latest block to determine who is alive. @@ -254,7 +264,9 @@ export class BlockService { } this.#rpcUrl = rpcUrls[index] - this.#client = createClient(this.#rpcUrl) + this.#otherLiveRpcUrls = rpcResults + .map((it, i) => (isProgress(it) && rpcUrls[i] !== this.#rpcUrl ? rpcUrls[i] : null)) + .filter((i) => i !== null) this.#attempt = 0 // We got a new block in the whole affair, handle it. @@ -293,13 +305,13 @@ export class BlockService { while (true) { // 1. Initialize next client if needed, or wait until next attempt. init: try { - if (!this.#client /* very first init */ || skipToNextClient) { + if (!this.#rpcUrl /* very first init */ || skipToNextClient) { await this.#nextRPC() break init } if (this.#attempt >= maxAttempts) { - blockLogger.warn(`Max retries (${maxAttempts}) reached for ${this.#client.name}.`) + blockLogger.warn(`Max retries (${maxAttempts}) reached for ${this.#rpcUrl}.`) await this.#nextRPC() break init } @@ -307,7 +319,7 @@ export class BlockService { if (this.#attempt > 1) { // We want first retry (attempt = 1) to be instant. const delay = Math.min(baseDelay * 2 ** (this.#attempt - 2), maxDelay) - blockLogger.info(`Waiting ${delay / 1000} seconds to retry with ${this.#client.name}`) + blockLogger.info(`Waiting ${delay / 1000} seconds to retry with ${this.#rpcUrl}`) await sleep(delay) } } catch (e) { @@ -316,8 +328,8 @@ export class BlockService { continue } - const client = this.#client.name - blockLogger.info(`Starting block watcher with ${client} (Attempt ${this.#attempt + 1}/${maxAttempts}).`) + const attemptString = `Attempt ${this.#attempt + 1}/${maxAttempts}` + blockLogger.info(`Starting block watcher with ${this.#rpcUrl} (${attemptString})`) // 2. Setup subscription const { promise, reject } = promiseWithResolvers() @@ -331,9 +343,9 @@ export class BlockService { this.#startBlockTimeout() - if (this.#client.transport.type === "webSocket") { + if (publicClient.transport.type === "webSocket") { try { - ;({ unsubscribe } = await this.#client.transport.subscribe({ + ;({ unsubscribe } = await publicClient.transport.subscribe({ params: ["newHeads"], // Type is unchecked, we're being conservative with what we receive. onData: (data?: { result?: Partial }) => { @@ -358,7 +370,7 @@ export class BlockService { } else { pollingTimer = setInterval(async () => { // biome-ignore format: terse - try { void this.#handleNewBlock(await this.#client.getBlock({ includeTransactions: false })) } + try { void this.#handleNewBlock(await publicClient.getBlock({ includeTransactions: false })) } catch (e) { reject(e) } }, pollingInterval) } @@ -367,7 +379,7 @@ export class BlockService { // it because we need to control the retry logic ourselves to implement RPC fallback // for subscriptions, as well as resubscriptions — two things Viem doesn't handle. - // unwatch = this.#client.watchBlocks({ + // unwatch = publicClient.watchBlocks({ // pollingInterval, // includeTransactions: false, // emitOnBegin: false, @@ -384,8 +396,8 @@ export class BlockService { clearTimeout(this.blockTimeout) // This happens more than the rest, and if the timeout persist, there will be plenty of other logs // for us to notice as the RPC will rotate. - if (e === TIMEOUT_MSG) blockLogger.info(TIMEOUT_MSG, client) - else blockLogger.error("Block watcher error", client, stringify(e)) + if (e === TIMEOUT_MSG) blockLogger.info(TIMEOUT_MSG, this.#rpcUrl) + else blockLogger.error("Block watcher error", this.#rpcUrl, stringify(e)) ++this.#attempt } } @@ -436,7 +448,7 @@ export class BlockService { for (let i = 1; i <= 3; ++i) { try { // `includeTransactions: false` still gives us a list of transaction hashes - block = await this.#client.getBlock({ blockNumber, includeTransactions: false }) + block = await publicClient.getBlock({ blockNumber, includeTransactions: false }) break } catch { await sleep(env.LINEAR_RETRY_DELAY * i) @@ -462,7 +474,7 @@ export class BlockService { // Check for duplicates if (block.hash === this.#current?.hash) { // Don't warn when polling, since this is expected to happen all the time. - if (this.#client.transport.type === "webSocket") + if (publicClient.transport.type === "webSocket") blockLogger.warn(`Received duplicate block ${block.number}, skipping.`) return false } @@ -507,7 +519,7 @@ export class BlockService { } if (this.#attempt > 0) { - blockLogger.info(`Retrieved block ${block.number} with ${this.#client.name}. Resetting attempt count.`) + blockLogger.info(`Retrieved block ${block.number} with ${this.#rpcUrl}. Resetting attempt count.`) this.#attempt = 0 } @@ -532,6 +544,8 @@ export class BlockService { return true } + #backfillMutex = new Mutex() + /** * Backfills blocks with numbers in [from, to] (inclusive). * Returns true iff the backfill is successful for the entire range. @@ -551,7 +565,7 @@ export class BlockService { const promises = [] for (let blockNumber = from; blockNumber <= to; blockNumber++) { - promises.push(this.#client.getBlock({ blockNumber, includeTransactions: false })) + promises.push(publicClient.getBlock({ blockNumber, includeTransactions: false })) } for (let blockNumber = from; blockNumber <= to; blockNumber++) { diff --git a/apps/submitter/lib/services/BoopNonceManager.ts b/apps/submitter/lib/services/BoopNonceManager.ts index 55f64c31c0..8b33db076b 100644 --- a/apps/submitter/lib/services/BoopNonceManager.ts +++ b/apps/submitter/lib/services/BoopNonceManager.ts @@ -4,9 +4,9 @@ import { abis, env } from "#lib/env" import type { SubmitError } from "#lib/handlers/submit" import { TraceMethod } from "#lib/telemetry/traces" import { type Boop, SubmitterError, type SubmitterErrorStatus } from "#lib/types" -import { publicClient } from "#lib/utils/clients" +import { computeHash } from "#lib/utils/boop/computeHash" import { logger } from "#lib/utils/logger" -import { computeHash } from "../utils/boop/computeHash" +import { publicClient } from "./clients" type NonceTrack = bigint type NonceValue = bigint diff --git a/apps/submitter/lib/services/EvmNonceManager.ts b/apps/submitter/lib/services/EvmNonceManager.ts index 0c0ed9ed8f..0560286cd3 100644 --- a/apps/submitter/lib/services/EvmNonceManager.ts +++ b/apps/submitter/lib/services/EvmNonceManager.ts @@ -1,7 +1,7 @@ import { type Address, HappyMap, Mutex, tryCatchAsync } from "@happy.tech/common" import type { GetTransactionCountErrorType } from "viem" -import { publicClient } from "#lib/utils/clients" import { logger } from "#lib/utils/logger" +import { publicClient } from "./clients" type _Import = GetTransactionCountErrorType diff --git a/apps/submitter/lib/services/EvmReceiptService.ts b/apps/submitter/lib/services/EvmReceiptService.ts index bb8ec449ff..d49c743ac7 100644 --- a/apps/submitter/lib/services/EvmReceiptService.ts +++ b/apps/submitter/lib/services/EvmReceiptService.ts @@ -9,9 +9,9 @@ import { } from "@happy.tech/common" import type { GetTransactionReceiptErrorType, TransactionReceipt } from "viem" import { env } from "#lib/env" -import { publicClient } from "#lib/utils/clients" import { logger } from "#lib/utils/logger" import type { BlockService } from "./BlockService" +import { publicClient } from "./clients" // biome-ignore format: pretty export type EvmReceiptResult = UnionFill< diff --git a/apps/submitter/lib/services/clients.ts b/apps/submitter/lib/services/clients.ts new file mode 100644 index 0000000000..30d0e08e7f --- /dev/null +++ b/apps/submitter/lib/services/clients.ts @@ -0,0 +1,168 @@ +import { type Fn, binaryPartition, uniques } from "@happy.tech/common" +import { + http, + type PublicClient as BasePublicClient, + type WalletClient as BaseWalletClient, + type Client, + type Transport, + createPublicClient as viemCreatePublicClient, + createWalletClient as viemCreateWalletClient, + webSocket, +} from "viem" +import { anvil, happychainTestnet } from "viem/chains" +import { env } from "#lib/env" +import { blockService } from "#lib/services/index" +import { logger } from "#lib/utils/logger" +import { isNonceTooLowError } from "#lib/utils/viem" + +// This file defines `publicClient` and `walletClient` as proxys that call a properly configured client +// with a RPC URL selected by the BlockService. They will use the RPCs that the BlockService finds to be live and +// updating blocks as fallback if the primary RPC fails. + +// NOTE: There are a number of ways that this could be improved: +// - In BlockService, we only check which RPCs are live and updating blocks whenever we need to change the primary RPC. +// These could be updated more often. +// - Similarly we could rotate back an RPC as the primary RPC if it comes back online. +// - These two probably requires a separate service that gathers statistics for RPCs. +// - Currently, when we enter RPC selection, the primary RPC remains the same for the duration of the selection. +// We could instead jettison it, though given we have the fallback it's probably not a big deal. + +function canonicalize(rpcs: readonly string[]): string[] { + return uniques( + rpcs.map((rpc) => { + let result = rpc.trim().replace("127.0.0.1", "localhost") + if (!rpc.includes("://")) result = `http://${result}` + if (result.endsWith("/")) result = result.slice(0, result.length - 1) + if (result === "https://rpc.testnet.happy.tech") result = `${result}/http` + if (result === "wss://rpc.testnet.happy.tech") result = `${result}/ws` + return result + }), + ) +} + +export const { chain, rpcUrls } = (() => { + const knownChain = [anvil, happychainTestnet].find((chain) => chain.id === env.CHAIN_ID) + const chainRpcs = knownChain?.rpcUrls.default + const rpcUrls = canonicalize(env.RPC_URLS ?? [...(chainRpcs?.webSocket ?? []), ...(chainRpcs?.http ?? [])]) + const [http, webSocket] = binaryPartition(rpcUrls, (url) => url.startsWith("http")) + + if (!rpcUrls.length) throw Error("Chain is not supported by default and RPC_URLS was not defined.") + + // TODO enable configuring custom chains + const chain = knownChain + ? { ...knownChain, rpcUrls: { default: { http, webSocket } } } + : { + id: env.CHAIN_ID, + name: "Blockchain", + rpcUrls: { default: { http, webSocket } }, + nativeCurrency: { symbol: "UNKNOWN", name: "UNKNOWN", decimals: 18 }, + } + + return { chain, rpcUrls } +})() + +const transportConfig = { + timeout: env.RPC_REQUEST_TIMEOUT, + batch: env.USE_RPC_BATCHING ? { wait: env.RPC_BATCH_WAIT, size: env.RPC_BATCH_SIZE } : false, +} as const + +export type PublicClient = BasePublicClient +export type WalletClient = BaseWalletClient + +function createPublicClient(rpcUrl: string): PublicClient { + return viemCreatePublicClient({ + chain, + transport: rpcUrl.startsWith("http") ? http(rpcUrl, transportConfig) : webSocket(rpcUrl, transportConfig), + }) +} + +function createWalletClient(rpcUrl: string): WalletClient { + return viemCreateWalletClient({ + chain, + transport: rpcUrl.startsWith("http") ? http(rpcUrl, transportConfig) : webSocket(rpcUrl, transportConfig), + }) +} + +abstract class ClientProxyHandler implements ProxyHandler { + abstract getCurrentClient(): T | null + abstract createAndSetClient(url: string): T + + get(_target: T, prop: string | symbol, _receiver: unknown) { + const url = blockService.getRpcUrl() + let currentClient = this.getCurrentClient() + if (!currentClient || currentClient.transport.url !== url) currentClient = this.createAndSetClient(url) + const value = Reflect.get(currentClient, prop) + if (typeof value !== "function") return value + + return (...args: unknown[]) => { + const otherRpcs = blockService.getOtherLiveRpcUrls() + for (let i = -1; i < otherRpcs.length; ++i) { + const client = i < 0 ? this.getCurrentClient() : createWalletClient(otherRpcs[i]) + if (!client) continue // only happens at initialization time + try { + return (Reflect.get(client, prop) as Fn)(...args) + } catch (err) { + if (this.shouldThrow(err)) throw err + } + } + } + } + + // NOTE: Originally written for the Viem `fallback` handler and still compatible with it. + shouldThrow(err: unknown): boolean { + if (!(err instanceof Error)) return false + + // The cases below indicate a properly functioning RPC reporting something wrong + // with the tx. There are properly other cases like that we haven't run into. + // It's okay, we're just going to be less efficient by doing needless retries. + + const msg = err.message + if (msg.includes("execution reverted")) return true + if (msg.includes("replacement transaction underpriced")) return true + if (msg.includes("Insufficient funds")) return true + + // This happens when resyncing and we reach max fees, ignore for `testResync.ts`, but don't ignore in general, + // in case a RPC is dysfunctional and we would benefit from sending the tx through another RPC. + if (env.NODE_ENV === "development" && msg.includes("transaction already imported")) return true + + // We handle this directly. + if (isNonceTooLowError(err)) return true + + logger.warn("RPC failed, falling back to next RPC:", msg) + return false // dont throw but proceed to the next RPC + } +} + +class PublicClientProxyHandler extends ClientProxyHandler { + #publicClient: PublicClient | null = null + getCurrentClient() { + return this.#publicClient + } + createAndSetClient(url: string) { + this.#publicClient = createPublicClient(url) + return this.#publicClient + } +} + +class WalletClientProxyHandler extends ClientProxyHandler { + #walletClient: WalletClient | null = null + getCurrentClient() { + return this.#walletClient + } + createAndSetClient(url: string) { + this.#walletClient = createWalletClient(url) + return this.#walletClient + } +} + +/** + * Proxy to a Viem public client to be used by the submitter. The underlying client is created with the latest RPC URL + * selected by the {@link BlockService} — this can change over time, hence the need for a proxy. + */ +export const publicClient: PublicClient = new Proxy({} as PublicClient, new PublicClientProxyHandler()) + +/** + * Proxy to a Viem wallet client to be used by the submitter. The underlying client is created with the latest RPC URL + * selected by the {@link BlockService} — this can change over time, hence the need for a proxy. + */ +export const walletClient: WalletClient = new Proxy({} as WalletClient, new WalletClientProxyHandler()) diff --git a/apps/submitter/lib/services/index.ts b/apps/submitter/lib/services/index.ts index ae8bbf006b..7c5f72369c 100644 --- a/apps/submitter/lib/services/index.ts +++ b/apps/submitter/lib/services/index.ts @@ -31,3 +31,4 @@ export { computeHash } from "../utils/boop/computeHash" export { findExecutionAccount } from "./evmAccounts" export { resyncAccount, resyncAllAccounts } from "./resync" export { replaceTransaction } from "./replaceTransaction" +export { publicClient, walletClient } from "./clients" diff --git a/apps/submitter/lib/services/replaceTransaction.ts b/apps/submitter/lib/services/replaceTransaction.ts index 46c52589af..bd8f55db4a 100644 --- a/apps/submitter/lib/services/replaceTransaction.ts +++ b/apps/submitter/lib/services/replaceTransaction.ts @@ -4,12 +4,12 @@ import { env } from "#lib/env" import { blockService, evmNonceManager } from "#lib/services" import { traceFunction } from "#lib/telemetry/traces" import type { EvmTxInfo } from "#lib/types" -import { isNonceTooLowError, publicClient, walletClient } from "#lib/utils/clients" import { getFees, getLatestBaseFee } from "#lib/utils/gas" import { logger } from "#lib/utils/logger" +import { isNonceTooLowError } from "#lib/utils/viem" +import { publicClient, walletClient } from "./clients" -// TODO: use this in BoopReceiptService -// TODO: can we unify this with resync, with a function that operates on [low, high] nonce range? +// TODO: can we unify this with resync.ts, with a function that operates on [low, high] nonce range? // could we then also attempt to coalesce adjacent nonce transaction cancellations? /** diff --git a/apps/submitter/lib/services/resync.ts b/apps/submitter/lib/services/resync.ts index fe4c291f4c..d081e026a5 100644 --- a/apps/submitter/lib/services/resync.ts +++ b/apps/submitter/lib/services/resync.ts @@ -3,9 +3,9 @@ import type { Account, Hash } from "viem" import { env } from "#lib/env" import { blockService } from "#lib/services" import { traceFunction } from "#lib/telemetry/traces" -import { publicClient, walletClient } from "#lib/utils/clients" import { getFees, getLatestBaseFee } from "#lib/utils/gas" import { resyncLogger } from "#lib/utils/logger" +import { publicClient, walletClient } from "./clients" import { accountDeployer, executorAccounts } from "./evmAccounts" // TODO Most of this is based upon the incorrect assumption that `getBlock({ block: "pending" }) would return diff --git a/apps/submitter/lib/utils/clients.ts b/apps/submitter/lib/utils/clients.ts deleted file mode 100644 index 857ddfc5f0..0000000000 --- a/apps/submitter/lib/utils/clients.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { stringify as _stringify, binaryPartition, uniques } from "@happy.tech/common" -import { - BaseError, - type PublicClient as BasePublicClient, - type WalletClient as BaseWalletClient, - webSocket, -} from "viem" -import { http, createPublicClient, createWalletClient, fallback } from "viem" -import { anvil, happychainTestnet } from "viem/chains" -import { env } from "#lib/env" -import { logger } from "#lib/utils/logger" - -function canonicalize(rpcs: readonly string[]): string[] { - return uniques( - rpcs.map((rpc) => { - let result = rpc.trim().replace("127.0.0.1", "localhost") - if (!rpc.includes("://")) result = `http://${result}` - if (result.endsWith("/")) result = result.slice(0, result.length - 1) - if (result === "https://rpc.testnet.happy.tech") result = `${result}/http` - if (result === "wss://rpc.testnet.happy.tech") result = `${result}/ws` - return result - }), - ) -} - -export const { chain, rpcUrls } = (() => { - const knownChain = [anvil, happychainTestnet].find((chain) => chain.id === env.CHAIN_ID) - const chainRpcs = knownChain?.rpcUrls.default - const rpcUrls = canonicalize(env.RPC_URLS ?? [...(chainRpcs?.webSocket ?? []), ...(chainRpcs?.http ?? [])]) - const [http, webSocket] = binaryPartition(rpcUrls, (url) => url.startsWith("http")) - - if (!rpcUrls.length) throw Error("Chain is not supported by default and RPC_URLS was not defined.") - - const chain = knownChain - ? { ...knownChain, rpcUrls: { default: { http, webSocket } } } - : { - id: env.CHAIN_ID, - name: "Blockchain", - rpcUrls: { default: { http, webSocket } }, - nativeCurrency: { symbol: "UNKNOWN", name: "UNKNOWN", decimals: 18 }, - } - - return { chain, rpcUrls } -})() - -function transport(url: string) { - const config = { - timeout: env.RPC_REQUEST_TIMEOUT, - batch: env.USE_RPC_BATCHING ? { wait: env.RPC_BATCH_WAIT, size: env.RPC_BATCH_WAIT } : false, - } - return url.startsWith("http") ? http(url, config) : webSocket(url, config) -} - -export const config = { - chain, - transport: fallback(rpcUrls.map(transport), { shouldThrow }), -} as const - -export type PublicClient = BasePublicClient -export const publicClient: PublicClient = createPublicClient(config) - -export type WalletClient = BaseWalletClient -export const walletClient: WalletClient = createWalletClient(config) - -export function isNonceTooLowError(error: unknown) { - return ( - error instanceof Error && - (error.message.includes("nonce too low") || error.message.includes("is lower than the current nonce")) - ) -} - -// For Viem to know whether to throw or fallback to next RPC -function shouldThrow(err: Error): boolean { - // The cases below indicate a properly functioning RPC reporting something wrong - // with the tx. There are properly other cases like that we haven't run into. - // It's okay, we're just going to be less efficient by doing needless retries. - - const msg = err.message - if (msg.includes("execution reverted")) return true - if (msg.includes("replacement transaction underpriced")) return true - if (msg.includes("Insufficient funds")) return true - - // This happens when resyncing and we reach max fees, ignore for `testResync.ts`, but don't ignore in general, - // in case a RPC is dysfunctional and we would benefit from sending the tx through another RPC. - if (env.NODE_ENV === "development" && msg.includes("transaction already imported")) return true - - // This gets handled in the receipt service. - if (isNonceTooLowError(err)) return true - - logger.warn("RPC failed, falling back to next RPC:", msg) - return false // dont throw but proceed to the next RPC -} - -export function stringify(value: unknown): string { - // Cut on the verbosity - if (value instanceof BaseError) return value.message - return _stringify(value) -} diff --git a/apps/submitter/lib/utils/test/anvil.ts b/apps/submitter/lib/utils/test/anvil.ts index 9a01a68475..f61901252f 100644 --- a/apps/submitter/lib/utils/test/anvil.ts +++ b/apps/submitter/lib/utils/test/anvil.ts @@ -1,9 +1,10 @@ import { ifDef } from "@happy.tech/common" import { type TestClient, createTestClient } from "viem" import { env } from "#lib/env" -import { config } from "#lib/utils/clients" +import { chain } from "#lib/services/clients" +import { transport } from "./helpers" -export const anvilClient: TestClient = createTestClient({ ...config, mode: "anvil" }) +export const anvilClient: TestClient = createTestClient({ chain, transport, mode: "anvil" }) export const USING_ANVIL = true // for now, only Anvil is supported const BLOCK_TIME = ifDef(process.env.ANVIL_BLOCK_TIME, Number) ?? 2 diff --git a/apps/submitter/lib/utils/test/helpers.ts b/apps/submitter/lib/utils/test/helpers.ts index c0396e51bc..171139f2a3 100644 --- a/apps/submitter/lib/utils/test/helpers.ts +++ b/apps/submitter/lib/utils/test/helpers.ts @@ -1,17 +1,31 @@ +import { abis, deployment, env } from "#lib/env" +import { type PublicClient, type WalletClient, chain, rpcUrls } from "#lib/services/clients" +import { findExecutionAccount } from "#lib/services/evmAccounts" + import { expect } from "bun:test" import type { Address } from "@happy.tech/common" import { abis as mockAbis, deployment as mockDeployments } from "@happy.tech/contracts/mocks/anvil" -import type { PrivateKeyAccount } from "viem" -import { decodeEventLog, zeroAddress } from "viem" +import { + http, + type PrivateKeyAccount, + createPublicClient, + createWalletClient, + decodeEventLog, + webSocket, + zeroAddress, +} from "viem" import { encodeFunctionData, parseEther } from "viem/utils" -import { abis, deployment, env } from "#lib/env" +// no barrel import: don't start service +import { EvmNonceManager } from "#lib/services/EvmNonceManager" import type { Boop, BoopReceipt } from "#lib/types" import { computeBoopHash } from "#lib/utils/boop" -import { publicClient, walletClient } from "#lib/utils/clients" -// no barrel import: don't start service -import { EvmNonceManager } from "#lib/services/EvmNonceManager" -import { findExecutionAccount } from "#lib/services/evmAccounts" +// Use bespoke clients to avoid getting the test logic tangled with RPC selection. +// If we start testing RPC selection, we should add a TEST_RPC variable to further isolate. + +export const transport = rpcUrls[0].startsWith("http") ? http(rpcUrls[0]) : webSocket(rpcUrls[0]) +export const publicClient: PublicClient = createPublicClient({ chain, transport }) +export const walletClient: WalletClient = createWalletClient({ chain, transport }) /** * Fetches the nonce using the configured deploy entryPoint diff --git a/apps/submitter/lib/utils/viem.ts b/apps/submitter/lib/utils/viem.ts new file mode 100644 index 0000000000..6d3385be0c --- /dev/null +++ b/apps/submitter/lib/utils/viem.ts @@ -0,0 +1,16 @@ +import { stringify as _stringify } from "@happy.tech/common" +import { BaseError } from "viem" + +// TODO might not be needed anymore +export function stringify(value: unknown): string { + // Cut on the verbosity + if (value instanceof BaseError) return value.message + return _stringify(value) +} + +export function isNonceTooLowError(error: unknown) { + return ( + error instanceof Error && + (error.message.includes("nonce too low") || error.message.includes("is lower than the current nonce")) + ) +} diff --git a/support/common/lib/utils/functions.ts b/support/common/lib/utils/functions.ts index d9299dbc10..b0f091ffac 100644 --- a/support/common/lib/utils/functions.ts +++ b/support/common/lib/utils/functions.ts @@ -1,7 +1,9 @@ /** - * Fully generic function type. Any functions can be assigned to `Fn`. + * Fully generic function type. Any functions can be assigned to `Fn` and it can be called with any parameters + * if the `Args` type parameter is not specified. */ -export type Fn = (...args: Args) => Result +// biome-ignore lint/suspicious/noExplicitAny: For both contravariance (assigning to type) and covariance (calling). +export type Fn = (...args: Args) => Result /** Union between `T` and functions returning `T`. */ export type Lazy = T extends CallableFunction ? never : T | (() => T)