diff --git a/tools/preconf-rpc/fastswap/fastswap.go b/tools/preconf-rpc/fastswap/fastswap.go index 7bf4a9f9e..17c0a0528 100644 --- a/tools/preconf-rpc/fastswap/fastswap.go +++ b/tools/preconf-rpc/fastswap/fastswap.go @@ -18,6 +18,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" fastsettlementv3 "github.com/primev/mev-commit/contracts-abi/clients/FastSettlementV3" + bidderapiv1 "github.com/primev/mev-commit/p2p/gen/go/bidderapi/v1" "github.com/primev/mev-commit/tools/preconf-rpc/sender" ) @@ -32,16 +33,17 @@ type SwapCall = fastsettlementv3.IFastSettlementV3SwapCall // SwapRequest is the HTTP request body for the /fastswap endpoint. type SwapRequest struct { - User common.Address `json:"user"` - InputToken common.Address `json:"inputToken"` - OutputToken common.Address `json:"outputToken"` - InputAmt *big.Int `json:"inputAmt"` - UserAmtOut *big.Int `json:"userAmtOut"` - Recipient common.Address `json:"recipient"` - Deadline *big.Int `json:"deadline"` - Nonce *big.Int `json:"nonce"` - Signature []byte `json:"signature"` // EIP-712 Permit2 signature - Slippage string `json:"slippage,omitempty"` // User slippage percentage (e.g. "1.0" for 1%) + User common.Address `json:"user"` + InputToken common.Address `json:"inputToken"` + OutputToken common.Address `json:"outputToken"` + InputAmt *big.Int `json:"inputAmt"` + UserAmtOut *big.Int `json:"userAmtOut"` + Recipient common.Address `json:"recipient"` + Deadline *big.Int `json:"deadline"` + Nonce *big.Int `json:"nonce"` + Signature []byte `json:"signature"` // EIP-712 Permit2 signature + Slippage string `json:"slippage,omitempty"` // User slippage percentage (e.g. "1.0" for 1%) + TopPercentile int32 `json:"topPercentile,omitempty"` // If 1-100, apply top-of-block PositionConstraint at this percentile. 0 = disabled. } // ToIntent converts SwapRequest to the generated Intent type for ABI encoding. @@ -455,6 +457,13 @@ func (s *Service) HandleSwap(ctx context.Context, req SwapRequest) (*SwapResult, Raw: rawTxHex, Type: sender.TxTypeFastSwap, } + if req.TopPercentile > 0 { + senderTx.Constraint = &bidderapiv1.PositionConstraint{ + Anchor: bidderapiv1.PositionConstraint_ANCHOR_TOP, + Basis: bidderapiv1.PositionConstraint_BASIS_PERCENTILE, + Value: req.TopPercentile, + } + } if err := s.txEnqueuer.Enqueue(ctx, senderTx); err != nil { return &SwapResult{ @@ -476,6 +485,7 @@ func (s *Service) HandleSwap(ctx context.Context, req SwapRequest) (*SwapResult, "gasFeeCap", gasFeeCap.String(), "gasTipCap", gasTipCap.String(), "nonce", nonce, + "topPercentile", req.TopPercentile, ) return &SwapResult{ @@ -497,16 +507,17 @@ func (s *Service) Handler() http.HandlerFunc { } var rawReq struct { - User string `json:"user"` - InputToken string `json:"inputToken"` - OutputToken string `json:"outputToken"` - InputAmt string `json:"inputAmt"` - UserAmtOut string `json:"userAmtOut"` - Recipient string `json:"recipient"` - Deadline string `json:"deadline"` - Nonce string `json:"nonce"` - Signature string `json:"signature"` - Slippage string `json:"slippage"` // Optional + User string `json:"user"` + InputToken string `json:"inputToken"` + OutputToken string `json:"outputToken"` + InputAmt string `json:"inputAmt"` + UserAmtOut string `json:"userAmtOut"` + Recipient string `json:"recipient"` + Deadline string `json:"deadline"` + Nonce string `json:"nonce"` + Signature string `json:"signature"` + Slippage string `json:"slippage"` // Optional + TopPercentile int32 `json:"topPercentile"` // Optional: 1-100 = apply top-of-block constraint, 0 = off } if err := json.NewDecoder(r.Body).Decode(&rawReq); err != nil { @@ -565,17 +576,23 @@ func (s *Service) Handler() http.HandlerFunc { return } + if rawReq.TopPercentile < 0 || rawReq.TopPercentile > 100 { + http.Error(w, "topPercentile must be between 0 and 100", http.StatusBadRequest) + return + } + req := SwapRequest{ - User: common.HexToAddress(rawReq.User), - InputToken: common.HexToAddress(rawReq.InputToken), - OutputToken: common.HexToAddress(rawReq.OutputToken), - InputAmt: inputAmt, - UserAmtOut: userAmtOut, - Recipient: common.HexToAddress(rawReq.Recipient), - Deadline: deadline, - Nonce: nonce, - Signature: signature, - Slippage: rawReq.Slippage, + User: common.HexToAddress(rawReq.User), + InputToken: common.HexToAddress(rawReq.InputToken), + OutputToken: common.HexToAddress(rawReq.OutputToken), + InputAmt: inputAmt, + UserAmtOut: userAmtOut, + Recipient: common.HexToAddress(rawReq.Recipient), + Deadline: deadline, + Nonce: nonce, + Signature: signature, + Slippage: rawReq.Slippage, + TopPercentile: rawReq.TopPercentile, } result, err := s.HandleSwap(r.Context(), req) diff --git a/tools/preconf-rpc/fastswap/fastswap_test.go b/tools/preconf-rpc/fastswap/fastswap_test.go index 68826300a..e7afc3015 100644 --- a/tools/preconf-rpc/fastswap/fastswap_test.go +++ b/tools/preconf-rpc/fastswap/fastswap_test.go @@ -14,6 +14,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" + bidderapiv1 "github.com/primev/mev-commit/p2p/gen/go/bidderapi/v1" "github.com/primev/mev-commit/tools/preconf-rpc/fastswap" "github.com/primev/mev-commit/tools/preconf-rpc/sender" "github.com/primev/mev-commit/x/util" @@ -313,6 +314,48 @@ func TestHandleSwap(t *testing.T) { require.Len(t, mockEnqueuer.enqueuedTxs, 1) enqueuedTx := mockEnqueuer.enqueuedTxs[0] require.Equal(t, mockSigner.address, enqueuedTx.Sender) + // Default (TopPercentile == 0): no PositionConstraint should be attached + require.Nil(t, enqueuedTx.Constraint) +} + +func TestHandleSwap_TopPercentile(t *testing.T) { + barterResp := newTestBarterResponse() + srv := setupTestServer(t, barterResp) + defer srv.Close() + + logger := util.NewTestLogger(os.Stdout) + settlementAddr := common.HexToAddress("0x1234567890123456789012345678901234567890") + svc := fastswap.NewService(srv.URL, "test-api-key", settlementAddr, 1, logger) + + mockSigner := &mockSigner{address: common.HexToAddress("0xExecutorAddress")} + mockEnqueuer := &mockTxEnqueuer{} + mockTracker := &mockBlockTracker{nonce: 5, nextBaseFee: big.NewInt(30000000000)} + mockStore := &mockNonceStore{nonce: 4, hasTxs: true} + svc.SetExecutorDeps(mockSigner, mockEnqueuer, mockTracker, mockStore) + + req := fastswap.SwapRequest{ + User: common.HexToAddress("0xUserAddress"), + InputToken: common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + OutputToken: common.HexToAddress("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"), + InputAmt: big.NewInt(1000000000), + UserAmtOut: big.NewInt(100), + Recipient: common.HexToAddress("0xRecipientAddress"), + Deadline: big.NewInt(1700000000), + Nonce: big.NewInt(1), + Signature: []byte{0x01, 0x02, 0x03, 0x04}, + TopPercentile: 10, + } + + result, err := svc.HandleSwap(context.Background(), req) + require.NoError(t, err) + require.Equal(t, "success", result.Status) + + require.Len(t, mockEnqueuer.enqueuedTxs, 1) + enqueuedTx := mockEnqueuer.enqueuedTxs[0] + require.NotNil(t, enqueuedTx.Constraint, "Constraint should be set when TopPercentile > 0") + require.Equal(t, bidderapiv1.PositionConstraint_ANCHOR_TOP, enqueuedTx.Constraint.Anchor) + require.Equal(t, bidderapiv1.PositionConstraint_BASIS_PERCENTILE, enqueuedTx.Constraint.Basis) + require.Equal(t, int32(10), enqueuedTx.Constraint.Value) } func TestHandleSwap_NoExecutorDeps(t *testing.T) { @@ -431,6 +474,101 @@ func TestHandler(t *testing.T) { require.Equal(t, "success", result.Status) } +func TestHandler_TopPercentile(t *testing.T) { + barterResp := newTestBarterResponse() + srv := setupTestServer(t, barterResp) + defer srv.Close() + + logger := util.NewTestLogger(os.Stdout) + settlementAddr := common.HexToAddress("0x1234567890123456789012345678901234567890") + + baseReq := func(topPercentile string) string { + body := `{ + "user": "0x0000000000000000000000000000000000000001", + "inputToken": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "outputToken": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "inputAmt": "1000000000", + "userAmtOut": "100", + "recipient": "0x0000000000000000000000000000000000000002", + "deadline": "1700000000", + "nonce": "1", + "signature": "0x01020304"` + if topPercentile != "" { + body += `, "topPercentile": ` + topPercentile + } + return body + `}` + } + + t.Run("valid percentile sets constraint", func(t *testing.T) { + svc := fastswap.NewService(srv.URL, "test-api-key", settlementAddr, 1, logger) + mockEnqueuer := &mockTxEnqueuer{} + svc.SetExecutorDeps( + &mockSigner{address: common.HexToAddress("0xExecutorAddress")}, + mockEnqueuer, + &mockBlockTracker{nonce: 0, nextBaseFee: big.NewInt(30000000000)}, + &mockNonceStore{nonce: 0, hasTxs: false}, + ) + + req := httptest.NewRequest(http.MethodPost, "/fastswap", strings.NewReader(baseReq("10"))) + w := httptest.NewRecorder() + svc.Handler()(w, req) + + require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) + require.Len(t, mockEnqueuer.enqueuedTxs, 1) + require.NotNil(t, mockEnqueuer.enqueuedTxs[0].Constraint) + require.Equal(t, int32(10), mockEnqueuer.enqueuedTxs[0].Constraint.Value) + }) + + t.Run("omitted field leaves constraint nil (backwards compat)", func(t *testing.T) { + svc := fastswap.NewService(srv.URL, "test-api-key", settlementAddr, 1, logger) + mockEnqueuer := &mockTxEnqueuer{} + svc.SetExecutorDeps( + &mockSigner{address: common.HexToAddress("0xExecutorAddress")}, + mockEnqueuer, + &mockBlockTracker{nonce: 0, nextBaseFee: big.NewInt(30000000000)}, + &mockNonceStore{nonce: 0, hasTxs: false}, + ) + + req := httptest.NewRequest(http.MethodPost, "/fastswap", strings.NewReader(baseReq(""))) + w := httptest.NewRecorder() + svc.Handler()(w, req) + + require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) + require.Len(t, mockEnqueuer.enqueuedTxs, 1) + require.Nil(t, mockEnqueuer.enqueuedTxs[0].Constraint) + }) + + t.Run("zero value leaves constraint nil", func(t *testing.T) { + svc := fastswap.NewService(srv.URL, "test-api-key", settlementAddr, 1, logger) + mockEnqueuer := &mockTxEnqueuer{} + svc.SetExecutorDeps( + &mockSigner{address: common.HexToAddress("0xExecutorAddress")}, + mockEnqueuer, + &mockBlockTracker{nonce: 0, nextBaseFee: big.NewInt(30000000000)}, + &mockNonceStore{nonce: 0, hasTxs: false}, + ) + + req := httptest.NewRequest(http.MethodPost, "/fastswap", strings.NewReader(baseReq("0"))) + w := httptest.NewRecorder() + svc.Handler()(w, req) + + require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) + require.Len(t, mockEnqueuer.enqueuedTxs, 1) + require.Nil(t, mockEnqueuer.enqueuedTxs[0].Constraint) + }) + + t.Run("out of range rejected", func(t *testing.T) { + svc := fastswap.NewService(srv.URL, "test-api-key", settlementAddr, 1, logger) + for _, val := range []string{"-1", "101", "500"} { + req := httptest.NewRequest(http.MethodPost, "/fastswap", strings.NewReader(baseReq(val))) + w := httptest.NewRecorder() + svc.Handler()(w, req) + require.Equal(t, http.StatusBadRequest, w.Code, "value=%s", val) + require.Contains(t, w.Body.String(), "topPercentile must be between 0 and 100") + } + }) +} + func TestHandler_MethodNotAllowed(t *testing.T) { logger := util.NewTestLogger(os.Stdout) svc := fastswap.NewService("", "", common.Address{}, 1, logger) diff --git a/tools/preconf-rpc/pricer/pricer.go b/tools/preconf-rpc/pricer/pricer.go index 07e3dc5f0..7d9c02b20 100644 --- a/tools/preconf-rpc/pricer/pricer.go +++ b/tools/preconf-rpc/pricer/pricer.go @@ -14,7 +14,7 @@ import ( "github.com/prometheus/client_golang/prometheus" ) -var apiURL = "https://api.blocknative.com/gasprices/blockprices?chainid=1&confidenceLevels=70,75,80,85" +var apiURL = "https://api.blocknative.com/gasprices/blockprices?chainid=1&confidenceLevels=70,75,80,85,90" type EstimatedPrice struct { Confidence int `json:"confidence"` @@ -150,6 +150,8 @@ func (b *BidPricer) syncEstimate(ctx context.Context) error { b.currentEstimates[int64(estimatedPrice.Confidence)] = estimatedPrice.PriorityFeePerGasGwei case 85: b.currentEstimates[int64(estimatedPrice.Confidence)] = estimatedPrice.PriorityFeePerGasGwei + case 90: + b.currentEstimates[int64(estimatedPrice.Confidence)] = estimatedPrice.PriorityFeePerGasGwei } b.metrics.bidPrices.WithLabelValues(fmt.Sprintf("%d", estimatedPrice.Confidence)).Set(estimatedPrice.PriorityFeePerGasGwei) } diff --git a/tools/preconf-rpc/pricer/pricer_test.go b/tools/preconf-rpc/pricer/pricer_test.go index a795f36a4..79e318f55 100644 --- a/tools/preconf-rpc/pricer/pricer_test.go +++ b/tools/preconf-rpc/pricer/pricer_test.go @@ -22,8 +22,8 @@ func TestEstimatePrice(t *testing.T) { prices := bp.EstimatePrice(ctx) - if len(prices) != 4 { - t.Fatalf("expected 4 confidence levels, got %d", len(prices)) + if len(prices) != 5 { + t.Fatalf("expected 5 confidence levels, got %d", len(prices)) } for confidence, price := range prices { diff --git a/tools/preconf-rpc/sender/sender.go b/tools/preconf-rpc/sender/sender.go index be10be9c7..e67127ce6 100644 --- a/tools/preconf-rpc/sender/sender.go +++ b/tools/preconf-rpc/sender/sender.go @@ -52,6 +52,8 @@ const ( defaultSwapConfidence = 70 // default confidence level for fastswap transactions confidenceSecondAttempt = 80 // confidence level for the second attempt confidenceSubsequentAttempts = 85 // confidence level for subsequent attempts + topOfBlockSwapConfidence = 80 // confidence level for top-of-block fastswap first attempt + topOfBlockSubsequentAttempts = 90 // confidence level for top-of-block fastswap retries transactionTimeout = 10 * time.Minute // timeout for transaction processing maxAttemptsPerBlock = 10 // maximum attempts per block defaultRetryDelay = 500 * time.Millisecond @@ -1112,6 +1114,14 @@ func (t *TxSender) calculatePriceForNextBlock( confidence = confidenceSubsequentAttempts } + if txn.Constraint != nil && txn.Type == TxTypeFastSwap { + if isRetry { + confidence = topOfBlockSubsequentAttempts + } else { + confidence = topOfBlockSwapConfidence + } + } + // If this is the first attempt for the next block, we add it to the history if !isRetry { attempts.attempts = append(attempts.attempts, &blockAttempt{