diff --git a/tools/preconf-rpc/fastswap/fastswap.go b/tools/preconf-rpc/fastswap/fastswap.go index dd2db9cc0..a80a58bbf 100644 --- a/tools/preconf-rpc/fastswap/fastswap.go +++ b/tools/preconf-rpc/fastswap/fastswap.go @@ -152,6 +152,11 @@ type BlockTracker interface { NextBaseFee() *big.Int } +// NonceStore interface for getting the current nonce from internal store +type NonceStore interface { + GetCurrentNonce(ctx context.Context, sender common.Address) (uint64, bool) +} + // Service handles FastSwap operations. type Service struct { barterBaseURL string @@ -164,6 +169,7 @@ type Service struct { signer Signer txEnqueuer TxEnqueuer blockTracker BlockTracker + nonceStore NonceStore } // NewService creates a new FastSwap service. @@ -189,10 +195,11 @@ func NewService( // SetExecutorDeps sets the dependencies needed for Path 1 executor transaction submission. // This is called after TxSender is created since there's a circular dependency. -func (s *Service) SetExecutorDeps(signer Signer, txEnqueuer TxEnqueuer, blockTracker BlockTracker) { +func (s *Service) SetExecutorDeps(signer Signer, txEnqueuer TxEnqueuer, blockTracker BlockTracker, nonceStore NonceStore) { s.signer = signer s.txEnqueuer = txEnqueuer s.blockTracker = blockTracker + s.nonceStore = nonceStore } // ============ Barter API ============ @@ -347,15 +354,30 @@ func (s *Service) HandleSwap(ctx context.Context, req SwapRequest) (*SwapResult, gasLimit += 100000 // Buffer for settlement contract overhead // 4. Get nonce for executor wallet + // Use same logic as sender's hasCorrectNonce executorAddr := s.signer.GetAddress() - nonce, err := s.blockTracker.AccountNonce(ctx, executorAddr) + maxNonce, hasTxs := s.nonceStore.GetCurrentNonce(ctx, executorAddr) + chainNonce, err := s.blockTracker.AccountNonce(ctx, executorAddr) if err != nil { return &SwapResult{ Status: "error", - Error: fmt.Sprintf("failed to get nonce: %v", err), + Error: fmt.Sprintf("failed to get chain nonce: %v", err), }, nil } + var nonce uint64 + if hasTxs { + // Has transactions in store, next nonce is max + 1 + nonce = maxNonce + 1 + } else { + // No transactions in store, use chain nonce + nonce = chainNonce + } + // If chain has advanced beyond our tracking, use chain nonce + if chainNonce > nonce { + nonce = chainNonce + } + // 5. Calculate gas pricing: GasFeeCap = NextBaseFee only (no tip needed, mev-commit bid handles inclusion) nextBaseFee := s.blockTracker.NextBaseFee() if nextBaseFee == nil || nextBaseFee.Sign() == 0 { @@ -396,12 +418,12 @@ func (s *Service) HandleSwap(ctx context.Context, req SwapRequest) (*SwapResult, } rawTxHex := "0x" + hex.EncodeToString(rawTxBytes) - // 9. Enqueue the transaction + // 9. Enqueue the transaction (uses TxTypeFastSwap to skip balance check) senderTx := &sender.Transaction{ Transaction: signedTx, Sender: executorAddr, Raw: rawTxHex, - Type: sender.TxTypeRegular, + Type: sender.TxTypeFastSwap, } if err := s.txEnqueuer.Enqueue(ctx, senderTx); err != nil { @@ -442,30 +464,86 @@ func (s *Service) Handler() http.HandlerFunc { return } - var req SwapRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + 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"` + } + + if err := json.NewDecoder(r.Body).Decode(&rawReq); err != nil { http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest) return } // Validate required fields - if req.User == (common.Address{}) { - http.Error(w, "missing user address", http.StatusBadRequest) + if rawReq.User == "" || !common.IsHexAddress(rawReq.User) { + http.Error(w, "missing or invalid user address", http.StatusBadRequest) + return + } + if rawReq.InputToken == "" || !common.IsHexAddress(rawReq.InputToken) { + http.Error(w, "missing or invalid inputToken", http.StatusBadRequest) return } - if req.InputToken == (common.Address{}) { - http.Error(w, "missing inputToken", http.StatusBadRequest) + if rawReq.OutputToken == "" || !common.IsHexAddress(rawReq.OutputToken) { + http.Error(w, "missing or invalid outputToken", http.StatusBadRequest) return } - if req.OutputToken == (common.Address{}) { - http.Error(w, "missing outputToken", http.StatusBadRequest) + if rawReq.Recipient == "" || !common.IsHexAddress(rawReq.Recipient) { + http.Error(w, "missing or invalid recipient", http.StatusBadRequest) return } - if len(req.Signature) == 0 { + if rawReq.Signature == "" { http.Error(w, "missing signature", http.StatusBadRequest) return } + // Parse big.Int fields + inputAmt, ok := new(big.Int).SetString(rawReq.InputAmt, 10) + if !ok || inputAmt.Sign() <= 0 { + http.Error(w, "invalid inputAmt", http.StatusBadRequest) + return + } + userAmtOut, ok := new(big.Int).SetString(rawReq.UserAmtOut, 10) + if !ok { + http.Error(w, "invalid userAmtOut", http.StatusBadRequest) + return + } + deadline, ok := new(big.Int).SetString(rawReq.Deadline, 10) + if !ok || deadline.Sign() <= 0 { + http.Error(w, "invalid deadline", http.StatusBadRequest) + return + } + nonce, ok := new(big.Int).SetString(rawReq.Nonce, 10) + if !ok { + http.Error(w, "invalid nonce", http.StatusBadRequest) + return + } + + // Decode signature from hex + signature, err := hex.DecodeString(strings.TrimPrefix(rawReq.Signature, "0x")) + if err != nil { + http.Error(w, "invalid signature hex", 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, + } + result, err := s.HandleSwap(r.Context(), req) if err != nil { http.Error(w, fmt.Sprintf("swap failed: %v", err), http.StatusInternalServerError) diff --git a/tools/preconf-rpc/fastswap/fastswap_test.go b/tools/preconf-rpc/fastswap/fastswap_test.go index 4dd761b2f..9b587981d 100644 --- a/tools/preconf-rpc/fastswap/fastswap_test.go +++ b/tools/preconf-rpc/fastswap/fastswap_test.go @@ -57,6 +57,16 @@ func (m *mockBlockTracker) NextBaseFee() *big.Int { return m.nextBaseFee } +// mockNonceStore implements fastswap.NonceStore interface +type mockNonceStore struct { + nonce uint64 + hasTxs bool +} + +func (m *mockNonceStore) GetCurrentNonce(_ context.Context, _ common.Address) (uint64, bool) { + return m.nonce, m.hasTxs +} + // ============ Test Helpers ============ func newTestBarterResponse() fastswap.BarterResponse { @@ -274,7 +284,8 @@ func TestHandleSwap(t *testing.T) { nonce: 5, nextBaseFee: big.NewInt(30000000000), // 30 gwei } - svc.SetExecutorDeps(mockSigner, mockEnqueuer, mockTracker) + mockStore := &mockNonceStore{nonce: 4, hasTxs: true} // store nonce + 1 should match tracker nonce + svc.SetExecutorDeps(mockSigner, mockEnqueuer, mockTracker, mockStore) req := fastswap.SwapRequest{ User: common.HexToAddress("0xUserAddress"), @@ -387,21 +398,22 @@ func TestHandler(t *testing.T) { nonce: 0, nextBaseFee: big.NewInt(30000000000), } - svc.SetExecutorDeps(mockSignerInst, mockEnqueuer, mockTracker) + mockStore := &mockNonceStore{nonce: 0, hasTxs: false} + svc.SetExecutorDeps(mockSignerInst, mockEnqueuer, mockTracker, mockStore) handler := svc.Handler() - // Use raw JSON with flattened request structure + // Use raw JSON with string values for numeric fields (new handler format) reqJSON := `{ "user": "0x0000000000000000000000000000000000000001", "inputToken": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "outputToken": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", - "inputAmt": 1000000000, - "userAmtOut": 500000000000000000, + "inputAmt": "1000000000", + "userAmtOut": "500000000000000000", "recipient": "0x0000000000000000000000000000000000000002", - "deadline": 1700000000, - "nonce": 1, - "signature": "AQIDBA==" + "deadline": "1700000000", + "nonce": "1", + "signature": "0x01020304" }` req := httptest.NewRequest(http.MethodPost, "/fastswap", strings.NewReader(reqJSON)) @@ -444,14 +456,14 @@ func TestHandler_MissingFields(t *testing.T) { { name: "missing user", reqBody: map[string]interface{}{}, - expected: "missing user address", + expected: "missing or invalid user address", }, { name: "missing inputToken", reqBody: map[string]interface{}{ "user": "0x1234567890123456789012345678901234567890", }, - expected: "missing inputToken", + expected: "missing or invalid inputToken", }, { name: "missing outputToken", @@ -459,7 +471,16 @@ func TestHandler_MissingFields(t *testing.T) { "user": "0x1234567890123456789012345678901234567890", "inputToken": "0x1234567890123456789012345678901234567890", }, - expected: "missing outputToken", + expected: "missing or invalid outputToken", + }, + { + name: "missing recipient", + reqBody: map[string]interface{}{ + "user": "0x1234567890123456789012345678901234567890", + "inputToken": "0x1234567890123456789012345678901234567890", + "outputToken": "0x1234567890123456789012345678901234567890", + }, + expected: "missing or invalid recipient", }, { name: "missing signature", @@ -467,7 +488,11 @@ func TestHandler_MissingFields(t *testing.T) { "user": "0x1234567890123456789012345678901234567890", "inputToken": "0x1234567890123456789012345678901234567890", "outputToken": "0x1234567890123456789012345678901234567890", - "inputAmt": 1000, + "recipient": "0x1234567890123456789012345678901234567890", + "inputAmt": "1000", + "userAmtOut": "900", + "deadline": "1700000000", + "nonce": "0", }, expected: "missing signature", }, diff --git a/tools/preconf-rpc/handlers/handlers.go b/tools/preconf-rpc/handlers/handlers.go index b2f510029..b9c38c52c 100644 --- a/tools/preconf-rpc/handlers/handlers.go +++ b/tools/preconf-rpc/handlers/handlers.go @@ -37,7 +37,7 @@ type Store interface { GetTransactionCommitments(ctx context.Context, txnHash common.Hash) ([]*bidderapiv1.Commitment, error) GetTransactionLogs(ctx context.Context, txnHash common.Hash) ([]*types.Log, error) GetBalance(ctx context.Context, account common.Address) (*big.Int, error) - GetCurrentNonce(ctx context.Context, account common.Address) uint64 + GetCurrentNonce(ctx context.Context, account common.Address) (uint64, bool) HasBalance(ctx context.Context, account common.Address, amount *big.Int) bool AlreadySubsidized(ctx context.Context, account common.Address) bool AddSubsidy(ctx context.Context, account common.Address, amount *big.Int) error @@ -687,18 +687,31 @@ func (h *rpcMethodHandler) handleGetTxCount(ctx context.Context, params ...any) ) } - accNonce := h.store.GetCurrentNonce(ctx, common.HexToAddress(account)) - if accNonce == 0 { - return nil, true, nil - } - - accNonce += 1 + maxNonce, hasTxs := h.store.GetCurrentNonce(ctx, common.HexToAddress(account)) + // Get backend nonce backendNonce, err := h.blockTracker.AccountNonce(ctx, common.HexToAddress(account)) - if err == nil { - if backendNonce > accNonce { - accNonce = backendNonce + if err != nil { + // If backend fails and no store txs, return nil (proxy will handle) + if !hasTxs { + return nil, true, nil } + // Otherwise use store nonce + 1 + backendNonce = 0 + } + + var accNonce uint64 + if hasTxs { + // Has transactions in store, next nonce is max + 1 + accNonce = maxNonce + 1 + } else { + // No transactions in store, use chain nonce + accNonce = backendNonce + } + + // If chain has advanced beyond our tracking, use chain nonce + if backendNonce > accNonce { + accNonce = backendNonce } nonceJSON, err := json.Marshal(accNonce) diff --git a/tools/preconf-rpc/sender/sender.go b/tools/preconf-rpc/sender/sender.go index 9cb83cbed..700dcd10a 100644 --- a/tools/preconf-rpc/sender/sender.go +++ b/tools/preconf-rpc/sender/sender.go @@ -27,6 +27,7 @@ const ( TxTypeRegular TxType = iota TxTypeDeposit TxTypeInstantBridge + TxTypeFastSwap // Executor-submitted fastswap transactions (skip balance check) ) type TxStatus string @@ -104,7 +105,7 @@ func effectiveFeePerGas(tx *types.Transaction) *big.Int { type Store interface { AddQueuedTransaction(ctx context.Context, tx *Transaction) error GetQueuedTransactions(ctx context.Context) ([]*Transaction, error) - GetCurrentNonce(ctx context.Context, sender common.Address) uint64 + GetCurrentNonce(ctx context.Context, sender common.Address) (uint64, bool) HasBalance(ctx context.Context, sender common.Address, amount *big.Int) bool AddBalance(ctx context.Context, account common.Address, amount *big.Int) error DeductBalance(ctx context.Context, account common.Address, amount *big.Int) error @@ -271,7 +272,7 @@ func validateTransaction(tx *Transaction) error { if tx == nil || tx.Transaction == nil { return ErrInvalidTransaction } - if tx.Type < TxTypeRegular || tx.Type > TxTypeInstantBridge { + if tx.Type < TxTypeRegular || tx.Type > TxTypeFastSwap { return ErrUnsupportedTxType } if tx.Raw == "" { @@ -290,17 +291,33 @@ func validateTransaction(tx *Transaction) error { } func (t *TxSender) hasCorrectNonce(ctx context.Context, tx *Transaction) error { - currentNonce := t.store.GetCurrentNonce(ctx, tx.Sender) + 1 + // Get backend (chain) nonce first backendNonce, err := t.blockTracker.AccountNonce(ctx, tx.Sender) - if err == nil { - if backendNonce > currentNonce { - currentNonce = backendNonce - } + if err != nil { + return fmt.Errorf("failed to get backend nonce: %w", err) + } + + // Get store nonce - returns (maxNonce, hasTxs) + maxNonce, hasTxs := t.store.GetCurrentNonce(ctx, tx.Sender) + + var expectedNonce uint64 + if hasTxs { + // Has transactions in store, next nonce is max + 1 + expectedNonce = maxNonce + 1 + } else { + // No transactions in store, use chain nonce + expectedNonce = backendNonce + } + + // If chain has advanced beyond our tracking, use chain nonce + if backendNonce > expectedNonce { + expectedNonce = backendNonce } + switch { - case tx.Nonce() < currentNonce: + case tx.Nonce() < expectedNonce: return ErrNonceTooLow - case tx.Nonce() > currentNonce: + case tx.Nonce() > expectedNonce: return ErrNonceTooHigh } @@ -847,6 +864,9 @@ func (t *TxSender) sendBid( ) } slashAmount = new(big.Int).Set(txn.Value()) + case TxTypeFastSwap: + // FastSwap executor transactions skip balance check - RPC bidder pays for bids + logger.Debug("FastSwap transaction - skipping balance check", "sender", txn.Sender.Hex()) } state := sim.Latest diff --git a/tools/preconf-rpc/sender/sender_test.go b/tools/preconf-rpc/sender/sender_test.go index 627bbfa2f..71fad15cc 100644 --- a/tools/preconf-rpc/sender/sender_test.go +++ b/tools/preconf-rpc/sender/sender_test.go @@ -70,16 +70,16 @@ func (m *mockStore) GetQueuedTransactions(_ context.Context) ([]*sender.Transact return txns, nil } -func (m *mockStore) GetCurrentNonce(_ context.Context, sender common.Address) uint64 { +func (m *mockStore) GetCurrentNonce(_ context.Context, sender common.Address) (uint64, bool) { m.mu.Lock() defer m.mu.Unlock() nonce, exists := m.nonce[sender] if !exists { - return 0 + return 0, false } - return nonce + return nonce, true } func (m *mockStore) HasBalance(ctx context.Context, sender common.Address, amount *big.Int) bool { @@ -349,7 +349,7 @@ func TestSender(t *testing.T) { tx1 := &sender.Transaction{ Transaction: types.NewTransaction( - 1, + 0, common.HexToAddress("0x1234567890123456789012345678901234567890"), big.NewInt(100), 21000, @@ -451,7 +451,7 @@ func TestSender(t *testing.T) { tx2 := &sender.Transaction{ Transaction: types.NewTransaction( - 2, + 1, common.HexToAddress("0x1234567890123456789012345678901234567890"), big.NewInt(1e18), 21000, @@ -604,7 +604,7 @@ func TestCancelTransaction(t *testing.T) { tx1 := &sender.Transaction{ Transaction: types.NewTransaction( - 1, + 0, common.HexToAddress("0x1234567890123456789012345678901234567890"), big.NewInt(100), 21000, @@ -693,7 +693,7 @@ func TestIgnoreProvidersOnRetry(t *testing.T) { tx1 := &sender.Transaction{ Transaction: types.NewTransaction( - 1, + 0, common.HexToAddress("0x1234567890123456789012345678901234567890"), big.NewInt(100), 21000, diff --git a/tools/preconf-rpc/service/service.go b/tools/preconf-rpc/service/service.go index 66c4f6789..61bb84f9d 100644 --- a/tools/preconf-rpc/service/service.go +++ b/tools/preconf-rpc/service/service.go @@ -456,7 +456,7 @@ func New(config *Config) (*Service, error) { // Wire executor dependencies for Path 1 (executor-submitted transactions) // Uses separate FastSwapSigner to isolate from main operational wallet if config.FastSwapSigner != nil { - fastswapSvc.SetExecutorDeps(config.FastSwapSigner, sndr, blockTracker) + fastswapSvc.SetExecutorDeps(config.FastSwapSigner, sndr, blockTracker, rpcstore) config.Logger.Info("FastSwap Path 1 enabled", "executorAddress", config.FastSwapSigner.GetAddress().Hex(), ) diff --git a/tools/preconf-rpc/store/store.go b/tools/preconf-rpc/store/store.go index 5af6fa826..ea4dd9295 100644 --- a/tools/preconf-rpc/store/store.go +++ b/tools/preconf-rpc/store/store.go @@ -476,23 +476,24 @@ func (s *rpcstore) GetTransactionCommitments(ctx context.Context, txnHash common return commitments, nil } -// GetCurrentNonce retrieves the next nonce for a given sender address by looking at the -// pending transactions in the database. If there are no pending transactions, it returns 0. -// The RPC would proxy this call to the underlying Ethereum node to get the current nonce in -// case if 0 is returned. -func (s *rpcstore) GetCurrentNonce(ctx context.Context, sender common.Address) uint64 { +// GetCurrentNonce retrieves the max nonce for a given sender address by looking at the +// non-failed transactions in the database. Returns (maxNonce, true) if transactions exist, +// or (0, false) if no transactions found. The caller should use the chain nonce when +// hasTxs is false. +func (s *rpcstore) GetCurrentNonce(ctx context.Context, sender common.Address) (uint64, bool) { query := ` - SELECT COALESCE(MAX(nonce), 0) + SELECT COALESCE(MAX(nonce), 0), COUNT(*) > 0 FROM mcTransactions WHERE sender = $1 AND status != 'failed'; ` row := s.db.QueryRowContext(ctx, query, sender.Hex()) - var nextNonce uint64 - err := row.Scan(&nextNonce) + var maxNonce uint64 + var hasTxs bool + err := row.Scan(&maxNonce, &hasTxs) if err != nil { - return 0 // If no pending transactions found, return 0 as the next nonce + return 0, false // If query fails, assume no transactions } - return nextNonce + return maxNonce, hasTxs } func (s *rpcstore) DeductBalance( diff --git a/tools/preconf-rpc/store/store_test.go b/tools/preconf-rpc/store/store_test.go index 31963d5fd..818852ee9 100644 --- a/tools/preconf-rpc/store/store_test.go +++ b/tools/preconf-rpc/store/store_test.go @@ -181,7 +181,10 @@ func TestStore(t *testing.T) { t.Run("GetCurrentNonce", func(t *testing.T) { senderAddress := common.HexToAddress("0xabcdefabcdefabcdefabcdefabcdefabcdefabcd") - nonce := st.GetCurrentNonce(context.Background(), senderAddress) + nonce, hasTxs := st.GetCurrentNonce(context.Background(), senderAddress) + if !hasTxs { + t.Fatalf("expected hasTxs to be true, got false") + } if nonce != 1 { t.Fatalf("expected nonce 1, got %d", nonce) }