diff --git a/src/demo_broadcast.ts b/src/demo_broadcast.ts new file mode 100644 index 0000000..88481d6 --- /dev/null +++ b/src/demo_broadcast.ts @@ -0,0 +1,121 @@ +import { BigNumber, providers, Wallet } from 'ethers' +import { FlashbotsBundleProvider, FlashbotsBundleResolution, BuilderBroadcaster } from './index' +import { TransactionRequest } from '@ethersproject/abstract-provider' +import { v4 as uuidv4 } from 'uuid' + +const FLASHBOTS_AUTH_KEY = process.env.FLASHBOTS_AUTH_KEY + +const GWEI = BigNumber.from(10).pow(9) +const PRIORITY_FEE = GWEI.mul(3) +const LEGACY_GAS_PRICE = GWEI.mul(57) +const BLOCKS_IN_THE_FUTURE = 2 + +// ===== Uncomment this for mainnet ======= +const CHAIN_ID = 1 +const provider = new providers.JsonRpcProvider( + { url: process.env.ETHEREUM_RPC_URL || 'http://127.0.0.1:8545' }, + { chainId: CHAIN_ID, ensAddress: '', name: 'mainnet' } +) +const FLASHBOTS_EP = 'https://relay.flashbots.net/' +// ===== Uncomment this for mainnet ======= + +// ===== Uncomment this for Goerli ======= +// const CHAIN_ID = 5 +// const provider = new providers.InfuraProvider(CHAIN_ID, process.env.INFURA_API_KEY) +// const FLASHBOTS_EP = 'https://relay-goerli.flashbots.net/' +// ===== Uncomment this for Goerli ======= + +for (const e of ['FLASHBOTS_AUTH_KEY', 'INFURA_API_KEY', 'ETHEREUM_RPC_URL', 'PRIVATE_KEY']) { + if (!process.env[e]) { + // don't warn for skipping ETHEREUM_RPC_URL if using goerli + if (FLASHBOTS_EP.includes('goerli') && e === 'ETHEREUM_RPC_URL') { + continue + } + console.warn(`${e} should be defined as an environment variable`) + } +} + +async function main() { + const authSigner = FLASHBOTS_AUTH_KEY ? new Wallet(FLASHBOTS_AUTH_KEY) : Wallet.createRandom() + const wallet = new Wallet(process.env.PRIVATE_KEY || '', provider) + const flashbotsProvider = await BuilderBroadcaster.createBroadcaster( + provider, + authSigner, + [ + "https://relay.flashbots.net", + "https://rpc.titanbuilder.xyz", + "https://builder0x69.io", + "https://rpc.beaverbuild.org", + "https://rsync-builder.xyz", + "https://api.blocknative.com/v1/auction", + // "https://mev.api.blxrbdn.com", # Authentication required + "https://eth-builder.com", + "https://builder.gmbit.co/rpc", + "https://buildai.net", + "https://rpc.payload.de", + "https://rpc.lightspeedbuilder.info", + "https://rpc.nfactorial.xyz", + ] + ) + + const legacyTransaction = { + to: wallet.address, + gasPrice: LEGACY_GAS_PRICE, + gasLimit: 21000, + data: '0x', + nonce: await provider.getTransactionCount(wallet.address), + chainId: CHAIN_ID + } + + provider.on('block', async (blockNumber) => { + const block = await provider.getBlock(blockNumber) + const replacementUuid = uuidv4() + + let eip1559Transaction: TransactionRequest + if (block.baseFeePerGas == null) { + console.warn('This chain is not EIP-1559 enabled, defaulting to two legacy transactions for demo') + eip1559Transaction = { ...legacyTransaction } + // We set a nonce in legacyTransaction above to limit validity to a single landed bundle. Delete that nonce for tx#2, and allow bundle provider to calculate it + delete eip1559Transaction.nonce + } else { + const maxBaseFeeInFutureBlock = FlashbotsBundleProvider.getMaxBaseFeeInFutureBlock(block.baseFeePerGas, BLOCKS_IN_THE_FUTURE) + eip1559Transaction = { + to: wallet.address, + type: 2, + maxFeePerGas: PRIORITY_FEE.add(maxBaseFeeInFutureBlock), + maxPriorityFeePerGas: PRIORITY_FEE, + gasLimit: 21000, + data: '0x', + chainId: CHAIN_ID + } + } + + const signedTransactions = await flashbotsProvider.signBundle([ + { + signer: wallet, + transaction: legacyTransaction + }, + { + signer: wallet, + transaction: eip1559Transaction + } + ]) + const targetBlock = blockNumber + BLOCKS_IN_THE_FUTURE + + const bundleSubmission = await flashbotsProvider.broadcastBundle(signedTransactions, targetBlock, { replacementUuid }) + console.log('bundle submitted, waiting') + if ('error' in bundleSubmission) { + throw new Error(bundleSubmission.error.message) + } + + const waitResponse = await bundleSubmission.wait() + console.log(`Wait Response: ${FlashbotsBundleResolution[waitResponse]}`) + if (waitResponse === FlashbotsBundleResolution.BundleIncluded || waitResponse === FlashbotsBundleResolution.AccountNonceTooHigh) { + process.exit(0) + } else { + console.log(bundleSubmission.bundleHashes) + } + }) +} + +main() diff --git a/src/index.ts b/src/index.ts index c5bdedd..896b44f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -67,6 +67,13 @@ export interface FlashbotsPrivateTransactionResponse { receipts: () => Promise> } +export interface BundleBroadcastResponse { + bundleTransactions: Array + wait: () => Promise + receipts: () => Promise> + bundleHashes: Array +} + export interface TransactionSimulationBase { txHash: string gasUsed: number @@ -113,6 +120,8 @@ export type SimulationResponse = SimulationResponseSuccess | RelayResponseError export type FlashbotsTransaction = FlashbotsTransactionResponse | RelayResponseError +export type BundleBroadcast = BundleBroadcastResponse | RelayResponseError + export type FlashbotsPrivateTransaction = FlashbotsPrivateTransactionResponse | RelayResponseError export interface GetUserStatsResponseSuccess { @@ -233,7 +242,7 @@ const TIMEOUT_MS = 5 * 60 * 1000 export class FlashbotsBundleProvider extends providers.JsonRpcProvider { private genericProvider: BaseProvider - private authSigner: Signer + protected authSigner: Signer private connectionInfo: ConnectionInfo constructor(genericProvider: BaseProvider, authSigner: Signer, connectionInfoOrUrl: ConnectionInfo, network: Networkish) { @@ -602,7 +611,7 @@ export class FlashbotsBundleProvider extends providers.JsonRpcProvider { * @param targetBlockNumber block number to check for bundle inclusion * @param timeout ms */ - private waitForBundleInclusion(transactionAccountNonces: Array, targetBlockNumber: number, timeout: number) { + protected waitForBundleInclusion(transactionAccountNonces: Array, targetBlockNumber: number, timeout: number) { return new Promise((resolve, reject) => { let timer: NodeJS.Timer | null = null let done = false @@ -1086,11 +1095,11 @@ export class FlashbotsBundleProvider extends providers.JsonRpcProvider { return fetchJson(connectionInfo, request) } - private async fetchReceipts(bundledTransactions: Array): Promise> { + protected async fetchReceipts(bundledTransactions: Array): Promise> { return Promise.all(bundledTransactions.map((bundledTransaction) => this.genericProvider.getTransactionReceipt(bundledTransaction.hash))) } - private prepareRelayRequest( + protected prepareRelayRequest( method: | 'eth_callBundle' | 'eth_cancelBundle' @@ -1111,3 +1120,118 @@ export class FlashbotsBundleProvider extends providers.JsonRpcProvider { } } } + +export class BuilderBroadcaster extends FlashbotsBundleProvider { + private connectionInfoArr: Array; + + constructor(genericProvider: BaseProvider, authSigner: Signer , network: Networkish, connectionInfoOrUrls: Array) { + const defaultConnectionInfo: ConnectionInfo = { url: DEFAULT_FLASHBOTS_RELAY } + super(genericProvider, authSigner, defaultConnectionInfo, network) + this.connectionInfoArr = connectionInfoOrUrls + } + + /** + * Creates a new Builder Bundle Broadcaster. + * @param genericProvider ethers.js mainnet provider + * @param authSigner account to sign bundles + * @param connectionInfoOrUrl (optional) connection settings + * @param network (optional) network settings + * + * @example + * ```typescript + * const {providers, Wallet} = require("ethers") + * const {BuilderBroadcaster} = require("@flashbots/ethers-provider-bundle") + * const authSigner = Wallet.createRandom() + * const provider = new providers.JsonRpcProvider("http://localhost:8545") + * const broadcaster = await BuilderBroadcaster.create(provider, authSigner, ['https://relay.flashbots.net/']) + * ``` + */ + static async createBroadcaster( + genericProvider: BaseProvider, + authSigner: Signer, + builderEndpoints: Array, + network?: Networkish + ): Promise { + const connectionInfoOrUrlArray: ConnectionInfo[] = Array.isArray(builderEndpoints) + ? builderEndpoints.map((b_e) => ({ url: b_e })) + : []; + + const networkish: Networkish = { + chainId: 0, + name: '' + } + if (typeof network === 'string') { + networkish.name = network + } else if (typeof network === 'number') { + networkish.chainId = network + } else if (typeof network === 'object') { + networkish.name = network.name + networkish.chainId = network.chainId + } + + if (networkish.chainId === 0) { + networkish.chainId = (await genericProvider.getNetwork()).chainId + } + + return new BuilderBroadcaster(genericProvider, authSigner, networkish, connectionInfoOrUrlArray) + } + + public async broadcastBundle( + signedBundledTransactions: Array, + targetBlockNumber: number, + opts?: FlashbotsOptions + ): Promise { + const params = { + txs: signedBundledTransactions, + blockNumber: `0x${targetBlockNumber.toString(16)}`, + minTimestamp: opts?.minTimestamp, + maxTimestamp: opts?.maxTimestamp, + revertingTxHashes: opts?.revertingTxHashes, + replacementUuid: opts?.replacementUuid + } + + const request = JSON.stringify(super.prepareRelayRequest('eth_sendBundle', [params])) + const responses = await this.requestBroadcast(request) + + const bundleTransactions = signedBundledTransactions.map((signedTransaction) => { + const transactionDetails = ethers.utils.parseTransaction(signedTransaction) + return { + signedTransaction, + hash: ethers.utils.keccak256(signedTransaction), + account: transactionDetails.from || '0x0', + nonce: transactionDetails.nonce + } + }) + + const bundleHashes = responses + .filter((response) => response.error === undefined || response.error === null) + .map((response) => response.result?.bundleHash) + .filter(Boolean); + + return { + bundleTransactions, + wait: () => super.waitForBundleInclusion(bundleTransactions, targetBlockNumber, TIMEOUT_MS), + receipts: () => super.fetchReceipts(bundleTransactions), + bundleHashes: bundleHashes + } + + } + + private async requestBroadcast(request: string) { + const responseHandles = new Array(); []; + for (let connectionInfo of this.connectionInfoArr) { + const updatedConnectionInfo = { ...connectionInfo }; + updatedConnectionInfo.headers = { + 'X-Flashbots-Signature': `${await this.authSigner.getAddress()}:${await this.authSigner.signMessage(id(request))}`, + ...connectionInfo.headers + }; + const promise = new Promise((resolve) => resolve(fetchJson(updatedConnectionInfo, request))); + responseHandles.push(promise); + } + + let responses = await Promise.all(responseHandles) + return responses + } + + +} \ No newline at end of file