From f01ef25b8ef41f9cd8bec2761a047325bf1fc7d9 Mon Sep 17 00:00:00 2001 From: Vitaly Drogan Date: Mon, 4 Nov 2024 13:13:47 +0100 Subject: [PATCH] Support share bundle cancellations --- go.mod | 2 +- go.sum | 4 +- proxy/api.go | 28 +++++++++- proxy/api_validate.go | 16 +++++- proxy/proxy.go | 11 ++++ proxy/proxy_test.go | 115 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 170 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index ce2c7c9..0d115c1 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.22 require ( github.com/VictoriaMetrics/metrics v1.35.1 github.com/ethereum/go-ethereum v1.14.10 - github.com/flashbots/go-utils v0.8.1-0.20241030134403-501d395be6a9 + github.com/flashbots/go-utils v0.8.1-0.20241104120502-7337c4b9d7b6 github.com/google/uuid v1.6.0 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/stretchr/testify v1.9.0 diff --git a/go.sum b/go.sum index e9b3104..d6abdca 100644 --- a/go.sum +++ b/go.sum @@ -54,8 +54,8 @@ github.com/ethereum/go-ethereum v1.14.10 h1:kC24WjYeRjDy86LVo6MfF5Xs7nnUu+XG4Aja github.com/ethereum/go-ethereum v1.14.10/go.mod h1:+l/fr42Mma+xBnhefL/+z11/hcmJ2egl+ScIVPjhc7E= github.com/ethereum/go-verkle v0.1.1-0.20240829091221-dffa7562dbe9 h1:8NfxH2iXvJ60YRB8ChToFTUzl8awsc3cJ8CbLjGIl/A= github.com/ethereum/go-verkle v0.1.1-0.20240829091221-dffa7562dbe9/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk= -github.com/flashbots/go-utils v0.8.1-0.20241030134403-501d395be6a9 h1:n0Xl5xg08GubAZRy/6aF7rBiyiSYpGlHoRiz957yWPg= -github.com/flashbots/go-utils v0.8.1-0.20241030134403-501d395be6a9/go.mod h1:Lo/nrlC+q8ANgT3e6MKALIJCU+V9qTSgNtoLk/q1uIw= +github.com/flashbots/go-utils v0.8.1-0.20241104120502-7337c4b9d7b6 h1:CYI4xzd3ho4p9tjm4j9vOKHOqes2zz0mSsgUXZVwUJ8= +github.com/flashbots/go-utils v0.8.1-0.20241104120502-7337c4b9d7b6/go.mod h1:Lo/nrlC+q8ANgT3e6MKALIJCU+V9qTSgNtoLk/q1uIw= github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps= github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= diff --git a/proxy/api.go b/proxy/api.go index bb91db7..f77a5ee 100644 --- a/proxy/api.go +++ b/proxy/api.go @@ -29,6 +29,8 @@ var ( errSubsidyWrongEndpoint = errors.New("subsidy can only be called on public method") errSubsidyWrongCaller = errors.New("subsidy can only be called by Flashbots") + errUUIDParse = errors.New("failed to parse UUID") + apiNow = time.Now ) @@ -146,7 +148,6 @@ func (prx *ReceiverProxy) MevSendBundle(ctx context.Context, mevSendBundle rpcty return err } - // TODO: make sure that cancellations are handled by the builder properly err = ValidateMevSendBundle(&mevSendBundle, publicEndpoint) if err != nil { return err @@ -156,8 +157,33 @@ func (prx *ReceiverProxy) MevSendBundle(ctx context.Context, mevSendBundle rpcty mevSendBundle.Metadata = &rpctypes.MevBundleMetadata{ Signer: &parsedRequest.signer, } + if mevSendBundle.ReplacementUUID != "" { + replUUID, err := uuid.Parse(mevSendBundle.ReplacementUUID) + if err != nil { + return errors.Join(errUUIDParse, err) + } + replacementKey := replacementNonceKey{ + uuid: replUUID, + signer: parsedRequest.signer, + } + // this is not atomic but the normal user will not send multiple replacements in parallel + nonce, ok := prx.replacementNonceRLU.Peek(replacementKey) + if ok { + nonce += 1 + } else { + nonce = 0 + } + prx.replacementNonceRLU.Add(replacementKey, nonce) + mevSendBundle.Metadata.ReplacementNonce = &nonce + + if len(mevSendBundle.Body) == 0 { + cancelled := true + mevSendBundle.Metadata.Cancelled = &cancelled + } + } } + // @note: unique key filterst same requests and it can interact with cancellations (you can't cancel multiple times per block) uniqueKey := mevSendBundle.UniqueKey() parsedRequest.requestArgUniqueKey = &uniqueKey diff --git a/proxy/api_validate.go b/proxy/api_validate.go index 4120314..13256d4 100644 --- a/proxy/api_validate.go +++ b/proxy/api_validate.go @@ -15,6 +15,8 @@ var ( errRefundPercent = errors.New("refund percent field should not be set") errRefundRecipient = errors.New("refund recipient field should not be set") errRefundTxHashes = errors.New("refund tx hashes field should not be set") + + errLocalEndpointSbundleMetadata = errors.New("mev share bundle should not containt metadata when sent to local endpoint") ) func ValidateEthSendBundle(args *rpctypes.EthSendBundleArgs, publicEndpoint bool) error { @@ -54,8 +56,18 @@ func ValidateEthCancelBundle(args *rpctypes.EthCancelBundleArgs, publicEndpoint return nil } -func ValidateMevSendBundle(args *rpctypes.MevSendBundleArgs, _ bool) error { +func ValidateMevSendBundle(args *rpctypes.MevSendBundleArgs, publicEndpoint bool) error { // @perf it calculates hash _, err := args.Validate() - return err + if err != nil { + return err + } + + if !publicEndpoint { + if args.Metadata != nil { + return errLocalEndpointSbundleMetadata + } + } + + return nil } diff --git a/proxy/proxy.go b/proxy/proxy.go index 6071d05..5cd7bb5 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -20,8 +20,16 @@ var ( requestsRLUTTL = time.Second * 12 peerUpdateTime = time.Minute * 5 + + replacementNonceSize = 4096 + replacementNonceTTL = time.Second * 5 * 12 ) +type replacementNonceKey struct { + uuid uuid.UUID + signer common.Address +} + type ReceiverProxy struct { ReceiverProxyConstantConfig @@ -48,6 +56,8 @@ type ReceiverProxy struct { requestUniqueKeysRLU *expirable.LRU[uuid.UUID, struct{}] + replacementNonceRLU *expirable.LRU[replacementNonceKey, int] + peerUpdaterClose chan struct{} } @@ -98,6 +108,7 @@ func NewReceiverProxy(config ReceiverProxyConfig) (*ReceiverProxy, error) { Certificate: certificate, localBuilder: localBuilder, requestUniqueKeysRLU: expirable.NewLRU[uuid.UUID, struct{}](requestsRLUSize, nil, requestsRLUTTL), + replacementNonceRLU: expirable.NewLRU[replacementNonceKey, int](replacementNonceSize, nil, replacementNonceTTL), } maxRequestBodySizeBytes := DefaultMaxRequestBodySizeBytes if config.MaxRequestBodySizeBytes != 0 { diff --git a/proxy/proxy_test.go b/proxy/proxy_test.go index 5504a95..ccff891 100644 --- a/proxy/proxy_test.go +++ b/proxy/proxy_test.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "log/slog" + "math/big" "net" "net/http" "net/http/httptest" @@ -13,6 +14,10 @@ import ( "testing" "time" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" "github.com/flashbots/go-utils/rpctypes" "github.com/flashbots/go-utils/signature" "github.com/stretchr/testify/require" @@ -357,3 +362,113 @@ func TestProxySendToArchive(t *testing.T) { expectedArchiveRequest := `{"method":"flashbots_newOrderEvents","params":[{"orderEvents":[{"eth_sendBundle":{"params":{"txs":null,"blockNumber":"0x7b","signingAddress":"0x9349365494be4f6205e5d44bdc7ec7dcd134becf"},"metadata":{"receivedAt":1730000000000}}},{"eth_sendBundle":{"params":{"txs":null,"blockNumber":"0x1c8","signingAddress":"0x9349365494be4f6205e5d44bdc7ec7dcd134becf"},"metadata":{"receivedAt":1730000000000}}}]}],"id":0,"jsonrpc":"2.0"}` require.Equal(t, expectedArchiveRequest, archiveRequest.body) } + +func createTestTx(i int) *hexutil.Bytes { + privateKey, err := crypto.HexToECDSA("c7589782d55a642c8ced7794ddcb24b62d4ebefbb81001034cb46545ff80e39e") + if err != nil { + panic(err) + } + + chainID := big.NewInt(1) + txData := &types.DynamicFeeTx{ + ChainID: chainID, + Nonce: uint64(i), + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(1), + Gas: 21000, + To: &common.Address{}, + Value: big.NewInt(0), + Data: nil, + AccessList: nil, + } + tx, err := types.SignNewTx(privateKey, types.LatestSignerForChainID(big.NewInt(1)), txData) + if err != nil { + panic(err) + } + binary, err := tx.MarshalBinary() + if err != nil { + panic(err) + } + hexBytes := hexutil.Bytes(binary) + return &hexBytes +} + +func TestProxyShareBundleReplacementUUIDAndCancellation(t *testing.T) { + defer func() { + proxiesFlushQueue() + for { + select { + case <-time.After(time.Millisecond * 100): + expectNoRequest(t, archiveServerRequests) + return + case <-archiveServerRequests: + } + } + }() + + signer, err := signature.NewSignerFromHexPrivateKey("0xd63b3c447fdea415a05e4c0b859474d14105a88178efdf350bc9f7b05be3cc58") + require.NoError(t, err) + client, err := RPCClientWithCertAndSigner(proxies[0].localServerEndpoint, proxies[0].proxy.PublicCertPEM, signer) + require.NoError(t, err) + + // we start with no peers + builderHubPeers = nil + err = proxies[0].proxy.RegisterSecrets(context.Background()) + require.NoError(t, err) + proxiesUpdatePeers(t) + + // first call + resp, err := client.Call(context.Background(), MevSendBundleMethod, &rpctypes.MevSendBundleArgs{ + Version: "v0.1", + ReplacementUUID: "550e8400-e29b-41d4-a716-446655440000", + Inclusion: rpctypes.MevBundleInclusion{ + BlockNumber: 10, + }, + Body: []rpctypes.MevBundleBody{ + { + Tx: createTestTx(0), + }, + }, + }) + require.NoError(t, err) + require.Nil(t, resp.Error) + + expectedRequest := `{"method":"mev_sendBundle","params":[{"version":"v0.1","replacementUuid":"550e8400-e29b-41d4-a716-446655440000","inclusion":{"block":"0xa","maxBlock":"0x0"},"body":[{"tx":"0x02f862018001018252089400000000000000000000000000000000000000008080c001a05900a5ea3e4e07980b0d6276e2764b734be64d64c20f3eb87746c7ed1d72aa26a073f3a0877bd80098bd720afd1ba2c6d2d9c76d87cbc23f098d7be74902d9bfd4"}],"validity":{},"metadata":{"signer":"0x9349365494be4f6205e5d44bdc7ec7dcd134becf","replacementNonce":0}}],"id":0,"jsonrpc":"2.0"}` + + builderRequest := expectRequest(t, proxies[0].localBuilderRequests) + require.Equal(t, expectedRequest, builderRequest.body) + + // second call + resp, err = client.Call(context.Background(), MevSendBundleMethod, &rpctypes.MevSendBundleArgs{ + Version: "v0.1", + ReplacementUUID: "550e8400-e29b-41d4-a716-446655440000", + Inclusion: rpctypes.MevBundleInclusion{ + BlockNumber: 10, + }, + Body: []rpctypes.MevBundleBody{ + { + Tx: createTestTx(1), + }, + }, + }) + require.NoError(t, err) + require.Nil(t, resp.Error) + + expectedRequest = `{"method":"mev_sendBundle","params":[{"version":"v0.1","replacementUuid":"550e8400-e29b-41d4-a716-446655440000","inclusion":{"block":"0xa","maxBlock":"0x0"},"body":[{"tx":"0x02f862010101018252089400000000000000000000000000000000000000008080c001a03b5edc6a7fe16f7c7bf25c56281b86107e742a922f900ac94293225b380fd5bea00f2ea6392842711064ca5c0fe12d812a60e8936ec8dc13ca95ecde8b262fd1fe"}],"validity":{},"metadata":{"signer":"0x9349365494be4f6205e5d44bdc7ec7dcd134becf","replacementNonce":1}}],"id":0,"jsonrpc":"2.0"}` + + builderRequest = expectRequest(t, proxies[0].localBuilderRequests) + require.Equal(t, expectedRequest, builderRequest.body) + + // cancell + resp, err = client.Call(context.Background(), MevSendBundleMethod, &rpctypes.MevSendBundleArgs{ + Version: "v0.1", + ReplacementUUID: "550e8400-e29b-41d4-a716-446655440000", + }) + require.NoError(t, err) + require.Nil(t, resp.Error) + + expectedRequest = `{"method":"mev_sendBundle","params":[{"version":"v0.1","replacementUuid":"550e8400-e29b-41d4-a716-446655440000","inclusion":{"block":"0x0","maxBlock":"0x0"},"body":null,"validity":{},"metadata":{"signer":"0x9349365494be4f6205e5d44bdc7ec7dcd134becf","replacementNonce":2,"cancelled":true}}],"id":0,"jsonrpc":"2.0"}` + + builderRequest = expectRequest(t, proxies[0].localBuilderRequests) + require.Equal(t, expectedRequest, builderRequest.body) +}