diff --git a/package-lock.json b/package-lock.json index a6db4c7..1e3ab89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -527,6 +527,14 @@ "integrity": "sha512-J32dgx2hw8vXrSbu4ZlVhn1Nm3GbeCFNw2FWL8S5QKucHGY0cyNwjdQdO+KMBZ4wpmC7KhLCiNsdk1RFRIYUQQ==", "dev": true }, + "@types/secp256k1": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/secp256k1/-/secp256k1-4.0.3.tgz", + "integrity": "sha512-Da66lEIFeIz9ltsdMZcpQvmrmmoqrfju8pm1BH8WbYjZSwUgCwXLb9C+9XYogwBITnbsSaMdVPb2ekf7TV+03w==", + "requires": { + "@types/node": "*" + } + }, "@typescript-eslint/eslint-plugin": { "version": "4.28.5", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.28.5.tgz", @@ -866,6 +874,16 @@ "esutils": "^2.0.2" } }, + "eciesjs": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.3.11.tgz", + "integrity": "sha512-WMyCAhS45hJdSzk2TZMYj9vo8+XjcsvnTG63MsIBmKgZbtTjViVSu201YmgfrjMuPZNm7gI7sJ8bkBR6ejN21A==", + "requires": { + "@types/secp256k1": "^4.0.2", + "futoin-hkdf": "^1.3.3", + "secp256k1": "^4.0.2" + } + }, "elliptic": { "version": "6.5.4", "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz", @@ -1216,6 +1234,11 @@ "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", "dev": true }, + "futoin-hkdf": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/futoin-hkdf/-/futoin-hkdf-1.4.2.tgz", + "integrity": "sha512-2BggwLEJOTfXzKq4Tl2bIT37p0IqqKkblH4e0cMp2sXTdmwg/ADBKMxvxaEytYYcgdxgng8+acsi3WgMVUl6CQ==" + }, "glob": { "version": "7.1.7", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", @@ -1485,6 +1508,16 @@ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, + "node-addon-api": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.2.tgz", + "integrity": "sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==" + }, + "node-gyp-build": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.2.3.tgz", + "integrity": "sha512-MN6ZpzmfNCRM+3t57PTJHgHyw/h4OWnZ6mR8P5j/uZtqQr46RRuDE/P+g3n0YR/AiYXeWixZZzaip77gdICfRg==" + }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -1628,6 +1661,16 @@ "integrity": "sha512-cdwTTnqPu0Hyvf5in5asVdZocVDTNRmR7XEcJuIzMjJeSHybHl7vpB66AzwTaIg6CLSbtjcxc8fqcySfnTkccA==", "dev": true }, + "secp256k1": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/secp256k1/-/secp256k1-4.0.2.tgz", + "integrity": "sha512-UDar4sKvWAksIlfX3xIaQReADn+WFnHvbVujpcbr+9Sf/69odMwy2MUsz5CKLQgX9nsIyrjuxL2imVyoNHa3fg==", + "requires": { + "elliptic": "^6.5.2", + "node-addon-api": "^2.0.0", + "node-gyp-build": "^4.2.0" + } + }, "semver": { "version": "7.3.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", diff --git a/package.json b/package.json index 4ad3965..0403528 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,8 @@ "ethers": "^5.4.2" }, "dependencies": { + "@ethersproject/rlp": "^5.4.0", + "eciesjs": "^0.3.11", "ts-node": "^9.1.0", "typescript": "^4.1.2" } diff --git a/src/index.ts b/src/index.ts index 07d7489..5e6a78f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,11 @@ import { BlockTag, TransactionReceipt, TransactionRequest } from '@ethersproject/abstract-provider' import { Networkish } from '@ethersproject/networks' -import { BaseProvider } from '@ethersproject/providers' +import { BaseProvider, TransactionResponse } from '@ethersproject/providers' import { ConnectionInfo, fetchJson } from '@ethersproject/web' import { BigNumber, ethers, providers, Signer } from 'ethers' import { id } from 'ethers/lib/utils' +import { encode } from '@ethersproject/rlp' +import { encrypt } from 'eciesjs' export const DEFAULT_FLASHBOTS_RELAY = 'https://relay.flashbots.net' @@ -28,6 +30,12 @@ export interface FlashbotsOptions { revertingTxHashes?: Array } +export interface FlashbotsBundle { + signedBundledTransactions: Array + blockTarget: number + options?: FlashbotsOptions +} + export interface TransactionAccountNonce { hash: string signedTransaction: string @@ -352,7 +360,7 @@ export class FlashbotsBundleProvider extends providers.JsonRpcProvider { public async getBundleStats(bundleHash: string, blockNumber: number): Promise { const evmBlockNumber = `0x${blockNumber.toString(16)}` - const params = [{bundleHash, blockNumber: evmBlockNumber}] + const params = [{ bundleHash, blockNumber: evmBlockNumber }] const request = JSON.stringify(this.prepareBundleRequest('flashbots_getBundleStats', params)) const response = await this.request(request) if (response.error !== undefined && response.error !== null) { @@ -415,6 +423,112 @@ export class FlashbotsBundleProvider extends providers.JsonRpcProvider { } } + /** + * Method to send a carrier tx into the public mempool + * + * @param bundle FlashbotsBundle with AT LEAST signed bundled transactions in signedBundledTransactions field obtained + * from {@link signBundle} method, and blockTarget. + * @param validatorPublicKey The public key of the validator that will be able to decrypt the bundle and include it + * into the bundle pool. + * @param signer Signer who will sign the carrier transaction. + * @param carrierTx TransactionRequest whose data field will carry the encrypted bundle : MAY be an incomplete + * object which will be populated with default values. + * + * @return Promise Promise containing the response for the carrier tx + * */ + + public async sendCarrierTransaction( + bundle: FlashbotsBundle, + validatorPublicKey: string, + signer: Signer, + carrierTx: TransactionRequest + ): Promise { + //RLP-serialize the given bundle + const serializedBundle = this.rlpSerializeBundle(bundle) + + //Encrypt the encoded bundle with the passed validator pub_key + const encryptedBundle = encrypt(validatorPublicKey, Buffer.from(serializedBundle)) + + //Populate carrier_tx.data as : carrier_tx.data = MEV_Prefix | validator pub_key | Encrypt(validator pub_key, serialized bundle) + const mevPrefix = `0123` //this is a placeholder! + + let payload = `0x` + payload += mevPrefix + payload += validatorPublicKey + payload += encryptedBundle.toString('hex') + + carrierTx.data = payload + + //Check if carrier_tx has minimum params, populate with defaults if not + /* + The following statement is intended to be used in order to support any type of incomplete TransactionRequest + received, populating it with default values if any one is missing + */ + await this.populateCarrierTransaction(carrierTx, signer) + + //Sign the transaction received as param with passed signer + const signedTx = await signer.signTransaction(carrierTx) + + //Propagate carrier_tx into the public mempool and return Promise for the carrier_tx + return this.genericProvider.sendTransaction(signedTx) + } + + /** + * A private method to encode a FlashbotsBundle following the RLP serialization standard + * @param bundle the FlashbotsBundle instance to be serialized + * @return string the rlp encoded bundle + * @private + */ + private rlpSerializeBundle(bundle: FlashbotsBundle): string { + if (bundle.signedBundledTransactions === undefined || bundle.signedBundledTransactions.length === 0) + throw Error('Bundle has no transactions') + if (bundle.options === undefined) bundle.options = {} + + const fields = [ + bundle.signedBundledTransactions, + this.formatNumber(bundle.blockTarget || 0), + this.formatNumber(bundle.options.minTimestamp || 0), + this.formatNumber(bundle.options.maxTimestamp || 0), + bundle.options.revertingTxHashes || [] + ] + return encode(fields) + } + + private formatNumber(num: number): string { + const hexNum = num.toString(16) + return hexNum.length % 2 === 0 ? `0x${hexNum}` : `0x0${hexNum}` + } + + /** + * A private method to populate {@param carrier}'s missing fields with default values + * @param carrier an instance of TransactionRequest which will be the tx containing the full payload in its data field + * @param signer the signer Object which will send the carrier tx + * @private + */ + private async populateCarrierTransaction(carrier: TransactionRequest, signer: Signer) { + if (!('to' in carrier)) throw Error('carrier.to field is missing') + + if (carrier.gasPrice != null) { + const gasPrice = BigNumber.from(carrier.gasPrice) + const maxFeePerGas = BigNumber.from(carrier.maxFeePerGas || 0) + if (!gasPrice.eq(maxFeePerGas)) { + throw Error('carrier tx EIP-1559 mismatch: gasPrice != maxFeePerGas') + } + } + const latestBlock = await this.genericProvider.getBlock('latest') + const blocksInFuture = 5 + const maxBaseFeeInFuture = FlashbotsBundleProvider.getMaxBaseFeeInFutureBlock(latestBlock.baseFeePerGas, blocksInFuture) + + carrier.type = 2 + carrier.chainId = carrier.chainId || 1 + carrier.nonce = carrier.nonce || (await this.genericProvider.getTransactionCount(signer.getAddress())) + carrier.maxPriorityFeePerGas = carrier.maxPriorityFeePerGas || ethers.utils.parseUnits('1.5', 'gwei') + carrier.maxFeePerGas = carrier.maxFeePerGas || maxBaseFeeInFuture.add(carrier.maxPriorityFeePerGas) + carrier.gasLimit = carrier.gasLimit || (await this.genericProvider.estimateGas(carrier)) + carrier.value = carrier.value || 0 + carrier.accessList = carrier.accessList || [] + } + private async request(request: string) { const connectionInfo = { ...this.connectionInfo } connectionInfo.headers = { @@ -428,7 +542,10 @@ export class FlashbotsBundleProvider extends providers.JsonRpcProvider { return Promise.all(bundledTransactions.map((bundledTransaction) => this.genericProvider.getTransactionReceipt(bundledTransaction.hash))) } - private prepareBundleRequest(method: 'eth_callBundle' | 'eth_sendBundle' | 'flashbots_getUserStats' | 'flashbots_getBundleStats', params: RpcParams) { + private prepareBundleRequest( + method: 'eth_callBundle' | 'eth_sendBundle' | 'flashbots_getUserStats' | 'flashbots_getBundleStats', + params: RpcParams + ) { return { method: method, params: params,