diff --git a/README.md b/README.md index 56ef6b1a..8b801151 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ ## Important links **[Install Skandha](https://etherspot.fyi/skandha/installation)** -| [Chains supported](https://etherspot.fyi/skandha/chains) +| [Chains supported](https://etherspot.fyi/prime-sdk/chains-supported)) | [UserOp Fee history](https://etherspot.fyi/skandha/feehistory) ## ⚙️ How to run (from Source code) diff --git a/packages/executor/src/config.ts b/packages/executor/src/config.ts index 6cc181d4..20a7eaf9 100644 --- a/packages/executor/src/config.ts +++ b/packages/executor/src/config.ts @@ -21,7 +21,13 @@ export class Config { static async init(configOptions: ConfigOptions): Promise { const config = new Config(configOptions); - await config.fetchChainId(); + try { + await config.fetchChainId(); + } catch (err) { + // trying again with skipping ssl errors + process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = "0"; + await config.fetchChainId(); + } return config; } @@ -150,10 +156,7 @@ export class Config { ); config.minStake = BigNumber.from( - fromEnvVar( - "MIN_STAKE", - config.minStake ?? bundlerDefaultConfigs.minStake - ) + fromEnvVar("MIN_STAKE", config.minStake ?? bundlerDefaultConfigs.minStake) ); config.minUnstakeDelay = Number( fromEnvVar( @@ -237,10 +240,7 @@ export class Config { ); config.banSlack = Number( - fromEnvVar( - "BAN_SLACK", - config.banSlack || bundlerDefaultConfigs.banSlack - ) + fromEnvVar("BAN_SLACK", config.banSlack || bundlerDefaultConfigs.banSlack) ); config.minInclusionDenominator = Number( @@ -261,7 +261,8 @@ export class Config { config.skipBundleValidation = Boolean( fromEnvVar( "SKIP_BUNDLE_VALIDATION", - config.skipBundleValidation || bundlerDefaultConfigs.skipBundleValidation + config.skipBundleValidation || + bundlerDefaultConfigs.skipBundleValidation ) ); @@ -293,6 +294,12 @@ export class Config { ) ); + config.fastlaneValidators = fromEnvVar( + "FASTLANE_VALIDATOR", + config.fastlaneValidators || bundlerDefaultConfigs.fastlaneValidators, + true + ) as string[]; + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions if (!config.whitelistedEntities) { config.whitelistedEntities = bundlerDefaultConfigs.whitelistedEntities; @@ -360,6 +367,7 @@ const bundlerDefaultConfigs: BundlerConfig = { kolibriAuthKey: "", entryPointForwarder: "", echoAuthKey: "", + fastlaneValidators: [], }; function getEnvVar(envVar: string, fallback: T): T | string { diff --git a/packages/executor/src/interfaces.ts b/packages/executor/src/interfaces.ts index 3d9e228f..ad50038e 100644 --- a/packages/executor/src/interfaces.ts +++ b/packages/executor/src/interfaces.ts @@ -164,6 +164,7 @@ export interface NetworkConfig { entryPointForwarder: string; // api auth key for echo: https://echo.chainbound.io/docs/usage/api-interface#authentication echoAuthKey: string; + fastlaneValidators: string[]; } export type BundlerConfig = Omit< diff --git a/packages/executor/src/modules/skandha.ts b/packages/executor/src/modules/skandha.ts index 93e36a70..2d6dc2aa 100644 --- a/packages/executor/src/modules/skandha.ts +++ b/packages/executor/src/modules/skandha.ts @@ -133,6 +133,7 @@ export class Skandha { gasFeeInSimulation: this.networkConfig.gasFeeInSimulation, userOpGasLimit: this.networkConfig.userOpGasLimit, bundleGasLimit: this.networkConfig.bundleGasLimit, + fastlaneValidators: this.networkConfig.fastlaneValidators }; } diff --git a/packages/executor/src/services/BundlingService/interfaces.ts b/packages/executor/src/services/BundlingService/interfaces.ts index 10cd560d..f2d4c65a 100644 --- a/packages/executor/src/services/BundlingService/interfaces.ts +++ b/packages/executor/src/services/BundlingService/interfaces.ts @@ -7,4 +7,5 @@ export interface IRelayingMode { isLocked(): boolean; sendBundle(bundle: Bundle): Promise; getAvailableRelayersCount(): number; + canSubmitBundle(): Promise; } diff --git a/packages/executor/src/services/BundlingService/relayers/base.ts b/packages/executor/src/services/BundlingService/relayers/base.ts index beba03d9..bb0210a9 100644 --- a/packages/executor/src/services/BundlingService/relayers/base.ts +++ b/packages/executor/src/services/BundlingService/relayers/base.ts @@ -44,6 +44,10 @@ export abstract class BaseRelayer implements IRelayingMode { return this.mutexes.filter((mutex) => !mutex.isLocked()).length; } + async canSubmitBundle(): Promise { + return true; + } + /** * waits entries to get submitted * @param hashes user op hashes array diff --git a/packages/executor/src/services/BundlingService/relayers/fastlane.ts b/packages/executor/src/services/BundlingService/relayers/fastlane.ts new file mode 100644 index 00000000..95a97eee --- /dev/null +++ b/packages/executor/src/services/BundlingService/relayers/fastlane.ts @@ -0,0 +1,271 @@ +import { providers } from "ethers"; +import { Logger } from "types/lib"; +import { PerChainMetrics } from "monitoring/lib"; +import { IEntryPoint__factory } from "types/lib/executor/contracts"; +import { chainsWithoutEIP1559 } from "params/lib"; +import { AccessList } from "ethers/lib/utils"; +import { MempoolEntryStatus } from "types/lib/executor"; +import { Relayer } from "../interfaces"; +import { Config } from "../../../config"; +import { Bundle, NetworkConfig, StorageMap } from "../../../interfaces"; +import { MempoolService } from "../../MempoolService"; +import { estimateBundleGasLimit } from "../utils"; +import { ReputationService } from "../../ReputationService"; +import { BaseRelayer } from "./base"; +import { now } from "../../../utils"; + +export class FastlaneRelayer extends BaseRelayer { + private submitTimeout = 10 * 60 * 1000; // 10 minutes + + constructor( + logger: Logger, + chainId: number, + provider: providers.JsonRpcProvider, + config: Config, + networkConfig: NetworkConfig, + mempoolService: MempoolService, + reputationService: ReputationService, + metrics: PerChainMetrics | null + ) { + super( + logger, + chainId, + provider, + config, + networkConfig, + mempoolService, + reputationService, + metrics + ); + if (!this.networkConfig.conditionalTransactions) { + throw new Error("Fastlane: You must enable conditional transactions"); + } + if (!this.networkConfig.rpcEndpointSubmit) { + throw new Error("Fastlane: You must set rpcEndpointSubmit"); + } + } + + async sendBundle(bundle: Bundle): Promise { + const availableIndex = this.getAvailableRelayerIndex(); + if (availableIndex == null) { + this.logger.error("Fastlane: No available relayers"); + return; + } + const relayer = this.relayers[availableIndex]; + const mutex = this.mutexes[availableIndex]; + + const { entries, storageMap } = bundle; + if (!bundle.entries.length) { + this.logger.error("Fastlane: Bundle is empty"); + return; + } + + await mutex.runExclusive(async (): Promise => { + const beneficiary = await this.selectBeneficiary(relayer); + const entryPoint = entries[0]!.entryPoint; + const entryPointContract = IEntryPoint__factory.connect( + entryPoint, + this.provider + ); + + const txRequest = entryPointContract.interface.encodeFunctionData( + "handleOps", + [entries.map((entry) => entry.userOp), beneficiary] + ); + + const transactionRequest: providers.TransactionRequest = { + to: entryPoint, + data: txRequest, + type: 2, + maxPriorityFeePerGas: bundle.maxPriorityFeePerGas, + maxFeePerGas: bundle.maxFeePerGas, + }; + + if (this.networkConfig.eip2930) { + const { storageMap } = bundle; + const addresses = Object.keys(storageMap); + if (addresses.length) { + const accessList: AccessList = []; + for (const address of addresses) { + const storageKeys = storageMap[address]; + if (typeof storageKeys == "object") { + accessList.push({ + address, + storageKeys: Object.keys(storageKeys), + }); + } + } + transactionRequest.accessList = accessList; + } + } + + if ( + chainsWithoutEIP1559.some((chainId: number) => chainId === this.chainId) + ) { + transactionRequest.gasPrice = bundle.maxFeePerGas; + delete transactionRequest.maxPriorityFeePerGas; + delete transactionRequest.maxFeePerGas; + delete transactionRequest.type; + delete transactionRequest.accessList; + } + + const transaction = { + ...transactionRequest, + gasLimit: estimateBundleGasLimit( + this.networkConfig.bundleGasLimitMarkup, + bundle.entries + ), + chainId: this.provider._network.chainId, + nonce: await relayer.getTransactionCount(), + }; + + if (!this.networkConfig.skipBundleValidation) { + try { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { gasLimit, ...txWithoutGasLimit } = transactionRequest; + // some chains, like Bifrost, don't allow setting gasLimit in estimateGas + await relayer.estimateGas(txWithoutGasLimit); + } catch (err) { + this.logger.debug( + `${entries + .map((entry) => entry.userOpHash) + .join("; ")} failed on chain estimation. deleting...` + ); + this.logger.error(err); + await this.mempoolService.removeAll(entries); + this.reportFailedBundle(); + return; + } + } + + this.logger.debug( + `Fastlane: Trying to submit userops: ${bundle.entries + .map((entry) => entry.userOpHash) + .join(", ")}` + ); + + await this.submitTransaction(relayer, transaction, storageMap) + .then(async (txHash: string) => { + this.logger.debug(`Fastlane: Bundle submitted: ${txHash}`); + this.logger.debug( + `Fastlane: User op hashes ${entries.map( + (entry) => entry.userOpHash + )}` + ); + await this.mempoolService.setStatus( + entries, + MempoolEntryStatus.Submitted, + txHash + ); + + await this.waitForEntries(entries).catch((err) => + this.logger.error(err, "Fastlane: Could not find transaction") + ); + this.reportSubmittedUserops(txHash, bundle); + }) + .catch(async (err: any) => { + this.reportFailedBundle(); + // Put all userops back to the mempool + // if some userop failed, it will be deleted inside handleUserOpFail() + await this.mempoolService.setStatus(entries, MempoolEntryStatus.New); + await this.handleUserOpFail(entries, err); + }); + }); + } + + async canSubmitBundle(): Promise { + try { + const provider = new providers.JsonRpcProvider( + "https://rpc-mainnet.maticvigil.com" + ); + const validators = await provider.send("bor_getCurrentValidators", []); + for (let fastlane of this.networkConfig.fastlaneValidators) { + fastlane = fastlane.toLowerCase(); + if ( + validators.some( + (validator: { signer: string }) => + validator.signer.toLowerCase() == fastlane + ) + ) { + return true; + } + } + } catch (err) { + this.logger.error(err, "Fastlane: error on bor_getCurrentValidators"); + } + return false; + } + + /** + * signs & sends a transaction + * @param relayer wallet + * @param transaction transaction request + * @param storageMap storage map + * @returns transaction hash + */ + private async submitTransaction( + relayer: Relayer, + transaction: providers.TransactionRequest, + storageMap: StorageMap + ): Promise { + const signedRawTx = await relayer.signTransaction(transaction); + const method = "pfl_sendRawTransactionConditional"; + + const provider = new providers.JsonRpcProvider( + this.networkConfig.rpcEndpointSubmit + ); + const submitStart = now(); + return new Promise((resolve, reject) => { + let lock = false; + const handler = async (blockNumber: number): Promise => { + if (now() - submitStart > this.submitTimeout) return reject("timeout"); + if (lock) return; + lock = true; + + const block = await relayer.provider.getBlock("latest"); + const params = [ + signedRawTx, + { + knownAccounts: storageMap, + blockNumberMin: block.number, + blockNumberMax: block.number + 180, // ~10 minutes + timestampMin: block.timestamp, + timestampMax: block.timestamp + 420, // 15 minutes + }, + ]; + + this.logger.debug({ + method, + ...transaction, + params, + }); + + this.logger.debug(`Fastlane: Trying to submit...`); + + try { + const hash = await provider.send(method, params); + this.logger.debug(`Fastlane: Sent new bundle ${hash}`); + this.provider.removeListener("block", handler); + return resolve(hash); + } catch (err: any) { + console.log(JSON.stringify(err, undefined, 2)); + if ( + !err || + !err.body || + !err.body.match(/is not participating in FastLane protocol/) + ) { + // some other error happened + this.provider.removeListener("block", handler); + return reject(err); + } + this.logger.debug( + `Fastlane: Validator is not participating in FastLane protocol. Trying again...` + ); + } finally { + lock = false; + } + }; + this.provider.on("block", handler); + }); + } +} diff --git a/packages/executor/src/services/BundlingService/relayers/index.ts b/packages/executor/src/services/BundlingService/relayers/index.ts index 19790911..d3910dee 100644 --- a/packages/executor/src/services/BundlingService/relayers/index.ts +++ b/packages/executor/src/services/BundlingService/relayers/index.ts @@ -3,16 +3,19 @@ import { FlashbotsRelayer } from "./flashbots"; import { MerkleRelayer } from "./merkle"; import { KolibriRelayer } from "./kolibri"; import { EchoRelayer } from "./echo"; +import { FastlaneRelayer } from "./fastlane"; export * from "./classic"; export * from "./flashbots"; export * from "./merkle"; export * from "./kolibri"; export * from "./echo"; +export * from "./fastlane"; export type RelayerClass = | typeof ClassicRelayer | typeof FlashbotsRelayer | typeof MerkleRelayer | typeof KolibriRelayer - | typeof EchoRelayer; + | typeof EchoRelayer + | typeof FastlaneRelayer; diff --git a/packages/executor/src/services/BundlingService/service.ts b/packages/executor/src/services/BundlingService/service.ts index 2229ba3f..47037ef4 100644 --- a/packages/executor/src/services/BundlingService/service.ts +++ b/packages/executor/src/services/BundlingService/service.ts @@ -31,6 +31,7 @@ import { RelayerClass, KolibriRelayer, EchoRelayer, + FastlaneRelayer, } from "./relayers"; import { getUserOpGasLimit } from "./utils"; @@ -72,6 +73,10 @@ export class BundlingService { } else if (relayingMode === "echo") { this.logger.debug(`Using echo relayer`); Relayer = EchoRelayer; + } else if (relayingMode === "fastlane") { + this.logger.debug(`Using fastlane relayer`); + Relayer = FastlaneRelayer; + this.maxSubmitAttempts = 5; } else { this.logger.debug(`Using classic relayer`); Relayer = ClassicRelayer; @@ -354,6 +359,10 @@ export class BundlingService { async sendNextBundle(): Promise { await this.mutex.runExclusive(async () => { + if (!await this.relayer.canSubmitBundle()) { + this.logger.debug("Relayer: Can not submit bundle yet"); + return; + } let relayersCount = this.relayer.getAvailableRelayersCount(); if (relayersCount == 0) { this.logger.debug("Relayers are busy"); @@ -368,7 +377,7 @@ export class BundlingService { } // remove entries from mempool if submitAttempts are greater than maxAttemps const invalidEntries = entries.filter( - (entry) => entry.submitAttempts >= this.maxSubmitAttempts + (entry) => entry.submitAttempts > this.maxSubmitAttempts ); if (invalidEntries.length > 0) { this.logger.debug( diff --git a/packages/types/src/api/interfaces.ts b/packages/types/src/api/interfaces.ts index 4ef3752d..d5cf754d 100644 --- a/packages/types/src/api/interfaces.ts +++ b/packages/types/src/api/interfaces.ts @@ -86,6 +86,7 @@ export type GetConfigResponse = { gasFeeInSimulation: boolean; userOpGasLimit: number; bundleGasLimit: number; + fastlaneValidators: string[]; }; export type SupportedEntryPoints = string[]; diff --git a/packages/types/src/executor/index.ts b/packages/types/src/executor/index.ts index 8a1fabf1..6812901e 100644 --- a/packages/types/src/executor/index.ts +++ b/packages/types/src/executor/index.ts @@ -9,7 +9,8 @@ export type RelayingMode = | "flashbots" | "classic" | "kolibri" - | "echo"; + | "echo" + | "fastlane"; export interface SendBundleReturn { transactionHash: string; userOpHashes: string[];