diff --git a/infrastructure/charts/mev-commit-preconf-rpc/templates/deployment.yaml b/infrastructure/charts/mev-commit-preconf-rpc/templates/deployment.yaml index b7e3ce7f9..002e21fe9 100644 --- a/infrastructure/charts/mev-commit-preconf-rpc/templates/deployment.yaml +++ b/infrastructure/charts/mev-commit-preconf-rpc/templates/deployment.yaml @@ -34,6 +34,7 @@ spec: - --log-tags={{ .Values.config.logTags }} {{- end }} - --keystore-dir={{ .Values.keystore.dir }} + - --simulation-url={{ .Values.config.simulationUrl }} - --keystore-password=$(KEYSTORE_PASSWORD) - --l1-rpc-urls={{ .Values.config.l1RpcUrls | join "," }} - --settlement-rpc-url={{ .Values.config.settlementRpcUrl }} diff --git a/infrastructure/charts/mev-commit-preconf-rpc/values.yaml b/infrastructure/charts/mev-commit-preconf-rpc/values.yaml index 0f6e81565..e48ad6f0a 100644 --- a/infrastructure/charts/mev-commit-preconf-rpc/values.yaml +++ b/infrastructure/charts/mev-commit-preconf-rpc/values.yaml @@ -63,6 +63,7 @@ config: httpPort: 8080 logLevel: "info" logFormat: "json" + simulationUrl: http://1.2.3.4 logTags: "" l1RpcUrls: - "" diff --git a/p2p/gen/openapi/debugapi/v1/debugapi.swagger.yaml b/p2p/gen/openapi/debugapi/v1/debugapi.swagger.yaml index 7f1033586..6273729b4 100644 --- a/p2p/gen/openapi/debugapi/v1/debugapi.swagger.yaml +++ b/p2p/gen/openapi/debugapi/v1/debugapi.swagger.yaml @@ -86,7 +86,7 @@ definitions: `NullValue` is a singleton enumeration to represent the null value for the `Value` type union. - The JSON representation for `NullValue` is JSON `null`. + The JSON representation for `NullValue` is JSON `null`. v1CancelTransactionResponse: type: object example: diff --git a/p2p/gen/openapi/notificationsapi/v1/notifications.swagger.yaml b/p2p/gen/openapi/notificationsapi/v1/notifications.swagger.yaml index 0fc3d83c1..58f9c5163 100644 --- a/p2p/gen/openapi/notificationsapi/v1/notifications.swagger.yaml +++ b/p2p/gen/openapi/notificationsapi/v1/notifications.swagger.yaml @@ -123,7 +123,7 @@ definitions: `NullValue` is a singleton enumeration to represent the null value for the `Value` type union. - The JSON representation for `NullValue` is JSON `null`. + The JSON representation for `NullValue` is JSON `null`. v1Notification: type: object properties: diff --git a/p2p/pkg/rpc/provider/service_test.go b/p2p/pkg/rpc/provider/service_test.go index f253375b7..535dea49a 100644 --- a/p2p/pkg/rpc/provider/service_test.go +++ b/p2p/pkg/rpc/provider/service_test.go @@ -925,7 +925,8 @@ func TestShutterisedBidOptionsProcessing(t *testing.T) { var bidOptionsBytes []byte var err error - if tc.name == "mixed bid options" { + switch tc.name { + case "mixed bid options": // Mixed options with position constraint and shutterised option bidderOpts = &bidderapiv1.BidOptions{ Options: []*bidderapiv1.BidOption{ @@ -948,7 +949,7 @@ func TestShutterisedBidOptionsProcessing(t *testing.T) { }, }, } - } else if tc.name == "multiple shutterised bid options" { + case "multiple shutterised bid options": // Multiple shutterised options - parse comma-separated values identityPrefixes := strings.Split(tc.identityPrefix, ",") encryptedTxs := strings.Split(tc.encryptedTx, ",") @@ -973,13 +974,13 @@ func TestShutterisedBidOptionsProcessing(t *testing.T) { }, }, } - } else if tc.name == "invalid bid options marshaling" { + case "invalid bid options marshaling": // Invalid protobuf data bidOptionsBytes = []byte("invalid protobuf data") - } else if tc.name == "nil shutterised bid option" { + case "nil shutterised bid option": // Nil bid options for nil case bidderOpts = nil - } else { + default: // Single valid shutterised option bidderOpts = &bidderapiv1.BidOptions{ Options: []*bidderapiv1.BidOption{ diff --git a/tools/preconf-rpc/handlers/handlers.go b/tools/preconf-rpc/handlers/handlers.go index 5d51d7436..1ce0b637a 100644 --- a/tools/preconf-rpc/handlers/handlers.go +++ b/tools/preconf-rpc/handlers/handlers.go @@ -20,6 +20,8 @@ const ( bridgeLimitWei = 1000000000000000000 // 1 ETH ) +type positionConstraintKey struct{} + type Bidder interface { Estimate() (int64, error) } @@ -32,6 +34,7 @@ type Store interface { GetTransactionByHash(ctx context.Context, txnHash common.Hash) (*sender.Transaction, error) GetTransactionsForBlock(ctx context.Context, blockNumber int64) ([]*sender.Transaction, error) 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 } @@ -45,6 +48,15 @@ type Sender interface { CancelTransaction(ctx context.Context, txHash common.Hash) (bool, error) } +func SetPositionConstraint(ctx context.Context, constraint *bidderapiv1.PositionConstraint) context.Context { + return context.WithValue(ctx, positionConstraintKey{}, constraint) +} + +func getPositionConstraint(ctx context.Context) (*bidderapiv1.PositionConstraint, bool) { + value, ok := ctx.Value(positionConstraintKey{}).(*bidderapiv1.PositionConstraint) + return value, ok +} + type rpcMethodHandler struct { logger *slog.Logger pricer Pricer @@ -356,12 +368,18 @@ func (h *rpcMethodHandler) handleSendRawTx( } } - err = h.sndr.Enqueue(ctx, &sender.Transaction{ + txnToEnqueue := &sender.Transaction{ Transaction: txn, Raw: rawTxHex, Sender: txSender, Type: txType, - }) + } + constraint, ok := getPositionConstraint(ctx) + if ok { + txnToEnqueue.Constraint = constraint + } + + err = h.sndr.Enqueue(ctx, txnToEnqueue) if err != nil { h.logger.Error("Failed to enqueue transaction for sending", "error", err, "sender", txSender.Hex()) return nil, false, rpcserver.NewJSONErr( @@ -415,6 +433,15 @@ func (h *rpcMethodHandler) handleGetTxReceipt(ctx context.Context, params ...any return nil, true, nil } + logs, err := h.store.GetTransactionLogs(ctx, txHash) + if err != nil { + h.logger.Error("Failed to get transaction logs", "error", err, "txHash", txHash) + return nil, false, rpcserver.NewJSONErr( + rpcserver.CodeCustomError, + "failed to get transaction logs", + ) + } + result := map[string]interface{}{ "type": hexutil.Uint(txn.Transaction.Type()), "transactionHash": txn.Hash().Hex(), @@ -424,7 +451,7 @@ func (h *rpcMethodHandler) handleGetTxReceipt(ctx context.Context, params ...any "contractAddress": (common.Address{}).Hex(), "gasUsed": hexutil.Uint64(0), "cumulativeGasUsed": hexutil.Uint64(1), - "logs": []*types.Log{}, // should be [] not null + "logs": logs, "logsBloom": hexutil.Bytes(types.Bloom{}.Bytes()), "effectiveGasPrice": hexutil.EncodeBig(big.NewInt(0)), } diff --git a/tools/preconf-rpc/main.go b/tools/preconf-rpc/main.go index 2bac4dd96..c8f501884 100644 --- a/tools/preconf-rpc/main.go +++ b/tools/preconf-rpc/main.go @@ -210,6 +210,13 @@ var ( Value: "", } + optionSimulationURL = &cli.StringFlag{ + Name: "simulation-url", + Usage: "URL for the transaction simulation service", + EnvVars: []string{"PRECONF_RPC_SIMULATION_URL"}, + Required: true, + } + optionLogFmt = &cli.StringFlag{ Name: "log-fmt", Usage: "log format to use, options are 'text' or 'json'", @@ -285,6 +292,7 @@ func main() { optionBidderThreshold, optionBidderTopup, optionAuthToken, + optionSimulationURL, }, Action: func(c *cli.Context) error { logger, err := util.NewLogger( @@ -370,6 +378,7 @@ func main() { PricerAPIKey: c.String(optionBlocknativeAPIKey.Name), Webhooks: c.StringSlice(optionWebhookURLs.Name), Token: c.String(optionAuthToken.Name), + SimulatorURL: c.String(optionSimulationURL.Name), } s, err := service.New(&config) diff --git a/tools/preconf-rpc/notifier/notifier.go b/tools/preconf-rpc/notifier/notifier.go index 185b134de..4060d5d02 100644 --- a/tools/preconf-rpc/notifier/notifier.go +++ b/tools/preconf-rpc/notifier/notifier.go @@ -10,11 +10,13 @@ import ( "log/slog" "math/big" "net/http" + "strings" "sync" "time" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/params" + bidderapiv1 "github.com/primev/mev-commit/p2p/gen/go/bidderapi/v1" "github.com/primev/mev-commit/tools/preconf-rpc/sender" "github.com/primev/mev-commit/x/util" ) @@ -222,6 +224,42 @@ func buildType(t txnInfo) string { } } +func buildConstraint(t txnInfo) string { + if t.txn.Constraint == nil { + return "None" + } + str := strings.Builder{} + switch t.txn.Constraint.Basis { + case bidderapiv1.PositionConstraint_BASIS_ABSOLUTE: + str.WriteString(fmt.Sprintf("Position %d", t.txn.Constraint.Value)) + switch t.txn.Constraint.Anchor { + case bidderapiv1.PositionConstraint_ANCHOR_TOP: + str.WriteString(" from Top") + case bidderapiv1.PositionConstraint_ANCHOR_BOTTOM: + str.WriteString(" from Bottom") + } + case bidderapiv1.PositionConstraint_BASIS_PERCENTILE: + switch t.txn.Constraint.Anchor { + case bidderapiv1.PositionConstraint_ANCHOR_TOP: + str.WriteString("Top ") + case bidderapiv1.PositionConstraint_ANCHOR_BOTTOM: + str.WriteString("Bottom ") + } + str.WriteString(fmt.Sprintf("%d%%", t.txn.Constraint.Value)) + case bidderapiv1.PositionConstraint_BASIS_GAS_PERCENTILE: + switch t.txn.Constraint.Anchor { + case bidderapiv1.PositionConstraint_ANCHOR_TOP: + str.WriteString("Top ") + case bidderapiv1.PositionConstraint_ANCHOR_BOTTOM: + str.WriteString("Bottom ") + } + str.WriteString(fmt.Sprintf("Gas Percentile %d%%", t.txn.Constraint.Value)) + default: + str.WriteString(" Unknown Basis") + } + return str.String() +} + func (n *Notifier) StartTransactionNotifier( ctx context.Context, ) <-chan struct{} { @@ -258,6 +296,11 @@ func (n *Notifier) StartTransactionNotifier( Field{Title: "Attempts", Value: fmt.Sprintf("%d", t.noOfAttempts), Short: true}, Field{Title: "Duration", Value: t.timeTaken.String(), Short: true}, ) + if t.txn.Constraint != nil { + fields = append(fields, + Field{Title: "Constraint", Value: buildConstraint(t), Short: true}, + ) + } } message := Message{ Text: "🚀 Transaction Report", diff --git a/tools/preconf-rpc/sender/sender.go b/tools/preconf-rpc/sender/sender.go index a08d46b69..8f781e23d 100644 --- a/tools/preconf-rpc/sender/sender.go +++ b/tools/preconf-rpc/sender/sender.go @@ -43,17 +43,19 @@ const ( confidenceSecondAttempt = 95 // confidence level for the second attempt confidenceSubsequentAttempts = 99 // confidence level for subsequent attempts transactionTimeout = 10 * time.Minute // timeout for transaction processing + maxAttemptsPerBlock = 10 // maximum attempts per block ) var ( - ErrInvalidTransaction = errors.New("invalid transaction") - ErrUnsupportedTxType = errors.New("unsupported transaction type") - ErrEmptyRawTransaction = errors.New("empty raw transaction") - ErrEmptyTransactionTo = errors.New("empty transaction 'to' address") - ErrNegativeTransactionValue = errors.New("negative transaction value") - ErrZeroGasLimit = errors.New("zero gas limit") - ErrTransactionCancelled = errors.New("transaction cancelled by user") - ErrTimeoutExceeded = errors.New("timeout exceeded while waiting for transaction to be processed") + ErrInvalidTransaction = errors.New("invalid transaction") + ErrUnsupportedTxType = errors.New("unsupported transaction type") + ErrEmptyRawTransaction = errors.New("empty raw transaction") + ErrEmptyTransactionTo = errors.New("empty transaction 'to' address") + ErrNegativeTransactionValue = errors.New("negative transaction value") + ErrZeroGasLimit = errors.New("zero gas limit") + ErrTransactionCancelled = errors.New("transaction cancelled by user") + ErrTimeoutExceeded = errors.New("timeout exceeded while waiting for transaction to be processed") + ErrMaxAttemptsPerBlockExceeded = errors.New("maximum attempts exceeded for transaction in the current block") ) type Transaction struct { @@ -64,6 +66,7 @@ type Transaction struct { Status TxStatus Details string BlockNumber int64 + Constraint *bidderapiv1.PositionConstraint } type Store interface { @@ -73,7 +76,7 @@ type Store interface { 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 - StoreTransaction(ctx context.Context, txn *Transaction, commitments []*bidderapiv1.Commitment) error + StoreTransaction(ctx context.Context, txn *Transaction, commitments []*bidderapiv1.Commitment, logs []*types.Log) error GetTransactionByHash(ctx context.Context, txnHash common.Hash) (*Transaction, error) } @@ -101,6 +104,10 @@ type Transferer interface { Transfer(ctx context.Context, to common.Address, chainID *big.Int, amount *big.Int) error } +type Simulator interface { + Simulate(ctx context.Context, txRaw string) ([]*types.Log, error) +} + type blockAttempt struct { blockNumber uint64 attempts int @@ -134,6 +141,7 @@ type TxSender struct { processMu sync.RWMutex txnAttemptHistory *lru.Cache[common.Hash, *txnAttempt] notifier Notifier + simulator Simulator fastTrack func(cmts []*bidderapiv1.Commitment, optedInSlot bool) bool bidTimeout time.Duration timeoutMtx sync.RWMutex @@ -150,8 +158,8 @@ func NewTxSender( blockTracker BlockTracker, transferer Transferer, notifier Notifier, + simulator Simulator, settlementChainId *big.Int, - fastTrack func(cmts []*bidderapiv1.Commitment, optedInSlot bool) bool, logger *slog.Logger, ) (*TxSender, error) { txnAttemptHistory, err := lru.New[common.Hash, *txnAttempt](1000) @@ -160,10 +168,6 @@ func NewTxSender( return nil, fmt.Errorf("failed to create transaction attempt history cache: %w", err) } - if fastTrack == nil { - fastTrack = noOpFastTrack - } - return &TxSender{ store: st, bidder: bidder, @@ -178,7 +182,8 @@ func NewTxSender( inflightAccount: make(map[common.Address]struct{}), txnAttemptHistory: txnAttemptHistory, notifier: notifier, - fastTrack: fastTrack, + simulator: simulator, + fastTrack: noOpFastTrack, bidTimeout: bidTimeout, }, nil } @@ -218,6 +223,10 @@ func (t *TxSender) triggerSender() { } } +func (t *TxSender) SetFastTrackFunc(fastTrack func(cmts []*bidderapiv1.Commitment, optedInSlot bool) bool) { + t.fastTrack = fastTrack +} + func (t *TxSender) Enqueue(ctx context.Context, tx *Transaction) error { if err := validateTransaction(tx); err != nil { t.logger.Error("Invalid transaction", "error", err, "transaction", tx.Raw) @@ -255,7 +264,7 @@ func (t *TxSender) CancelTransaction(ctx context.Context, txnHash common.Hash) ( if txn.Status == TxStatusPending { txn.Status = TxStatusFailed txn.Details = ErrTransactionCancelled.Error() - if err := t.store.StoreTransaction(ctx, txn, nil); err != nil { + if err := t.store.StoreTransaction(ctx, txn, nil, nil); err != nil { t.logger.Error("Failed to store cancelled transaction", "hash", txnHash.Hex(), "error", err) return false, fmt.Errorf("failed to store cancelled transaction: %w", err) } @@ -416,7 +425,7 @@ func (t *TxSender) processQueuedTransactions(ctx context.Context) { txn.Status = TxStatusFailed txn.Details = err.Error() t.clearBlockAttemptHistory(txn, time.Now()) - return t.store.StoreTransaction(ctx, txn, nil) + return t.store.StoreTransaction(ctx, txn, nil, nil) } return nil }) @@ -429,6 +438,11 @@ func (t *TxSender) processTransaction(ctx context.Context, txn *Transaction, can result bidResult err error ) + logger := t.logger.With( + "transactionHash", txn.Hash().Hex(), + "sender", txn.Sender.Hex(), + "type", txn.Type, + ) BID_LOOP: for { select { @@ -440,11 +454,13 @@ BID_LOOP: } preConfirmed := false + maxAttemptsPerBlockExceeded := false + result, err = t.sendBid(ctx, txn) switch { case err != nil: if retryErr, ok := err.(*errRetry); ok { - t.logger.Warn( + logger.Warn( "Retrying bid due to error", "error", retryErr.err, "retryAfter", retryErr.retryAfter, @@ -459,21 +475,24 @@ BID_LOOP: } continue } - return err + // If we exceeded max attempts per block, we retry for the next block but + // also check for inclusion in case the transaction got included + if !errors.Is(err, ErrMaxAttemptsPerBlockExceeded) { + return err + } else { + maxAttemptsPerBlockExceeded = true + } case t.fastTrack(result.commitments, result.optedInSlot): // If the commitments indicate that the transaction can be fast-tracked, // we consider it pre-confirmed and skip further checks txn.Status = TxStatusPreConfirmed txn.BlockNumber = int64(result.blockNumber) - t.logger.Info( + logger.Info( "Transaction fast-tracked based on commitments", - "transactionHash", txn.Hash().Hex(), - "sender", txn.Sender.Hex(), - "type", txn.Type, "blockNumber", result.blockNumber, "bidAmount", result.bidAmount.String(), ) - if err := t.store.StoreTransaction(ctx, txn, result.commitments); err != nil { + if err := t.store.StoreTransaction(ctx, txn, result.commitments, result.logs); err != nil { return fmt.Errorf("failed to store fast-tracked transaction: %w", err) } preConfirmed = true @@ -484,15 +503,12 @@ BID_LOOP: // user that the txn was successfully sent and will be processed txn.Status = TxStatusPreConfirmed txn.BlockNumber = int64(result.blockNumber) - t.logger.Info( + logger.Info( "Transaction pre-confirmed", - "transactionHash", txn.Hash().Hex(), - "sender", txn.Sender.Hex(), - "type", txn.Type, "blockNumber", result.blockNumber, "bidAmount", result.bidAmount.String(), ) - if err := t.store.StoreTransaction(ctx, txn, result.commitments); err != nil { + if err := t.store.StoreTransaction(ctx, txn, result.commitments, result.logs); err != nil { return fmt.Errorf("failed to store preconfirmed transaction: %w", err) } preConfirmed = true @@ -500,20 +516,17 @@ BID_LOOP: default: } - if !preConfirmed && result.noOfProviders > len(result.commitments) { - t.logger.Warn( + if !preConfirmed && result.noOfProviders > len(result.commitments) && !maxAttemptsPerBlockExceeded { + logger.Warn( "Not all builders committed to the bid", - "transactionHash", txn.Hash().Hex(), "noOfProviders", result.noOfProviders, "noOfCommitments", len(result.commitments), - "sender", txn.Sender.Hex(), - "type", txn.Type, "blockNumber", result.blockNumber, "bidAmount", result.bidAmount.String(), ) - if (result.timeUntillNextBlock - time.Second) > time.Since(result.startTime) { + if (result.timeUntillNextBlock - 2*time.Second) > time.Since(result.startTime) { // If not all builders committed, we will retry the bid process - // immediately if we have atleast 1 second left before the next block + // immediately if we have atleast 2 seconds left before the next block continue } } @@ -522,22 +535,19 @@ BID_LOOP: // we will retry the bid process till user cancels the operation included, err := t.blockTracker.CheckTxnInclusion(ctx, txn.Hash(), result.blockNumber) if err != nil { - t.logger.Error("Failed to check transaction inclusion", "error", err) + logger.Error("Failed to check transaction inclusion", "error", err) return fmt.Errorf("failed to check transaction inclusion: %w", err) } if included { if !preConfirmed { txn.Status = TxStatusConfirmed txn.BlockNumber = int64(result.blockNumber) - t.logger.Info( + logger.Info( "Transaction confirmed", - "transactionHash", txn.Hash().Hex(), - "sender", txn.Sender.Hex(), - "type", txn.Type, "blockNumber", result.blockNumber, "bidAmount", result.bidAmount.String(), ) - if err := t.store.StoreTransaction(ctx, txn, result.commitments); err != nil { + if err := t.store.StoreTransaction(ctx, txn, result.commitments, result.logs); err != nil { return fmt.Errorf("failed to store preconfirmed transaction: %w", err) } } @@ -553,19 +563,19 @@ BID_LOOP: switch txn.Type { case TxTypeRegular: if err := t.store.DeductBalance(ctx, txn.Sender, result.bidAmount); err != nil { - t.logger.Error("Failed to deduct balance for sender", "sender", txn.Sender.Hex(), "error", err) + logger.Error("Failed to deduct balance for sender", "error", err) return fmt.Errorf("failed to deduct balance for sender: %w", err) } case TxTypeDeposit: balanceToAdd := new(big.Int).Sub(txn.Value(), result.bidAmount) if err := t.store.AddBalance(ctx, txn.Sender, balanceToAdd); err != nil { - t.logger.Error("Failed to add balance for sender", "sender", txn.Sender.Hex(), "error", err) + logger.Error("Failed to add balance for sender", "error", err) return fmt.Errorf("failed to add balance for sender: %w", err) } case TxTypeInstantBridge: amountToBridge := new(big.Int).Sub(txn.Value(), new(big.Int).Mul(result.bidAmount, big.NewInt(2))) if err := t.transferer.Transfer(ctx, txn.Sender, t.settlementChainId, amountToBridge); err != nil { - t.logger.Error("Failed to transfer funds for instant bridge", "sender", txn.Sender.Hex(), "error", err) + logger.Error("Failed to transfer funds for instant bridge", "error", err) return fmt.Errorf("failed to transfer funds for instant bridge: %w", err) } } @@ -590,25 +600,38 @@ type bidResult struct { optedInSlot bool bidAmount *big.Int commitments []*bidderapiv1.Commitment + logs []*types.Log } func (t *TxSender) sendBid( ctx context.Context, txn *Transaction, ) (bidResult, error) { + start := time.Now() + logger := t.logger.With( + "transactionHash", txn.Hash().Hex(), + "sender", txn.Sender.Hex(), + "type", txn.Type, + ) + timeToOptIn, err := t.bidder.Estimate() if err != nil { - t.logger.Warn("Failed to estimate time to opt-in", "error", err) + logger.Warn("Failed to estimate time to opt-in", "error", err) // If we cannot estimate the time to opt-in, we assume a default value and // proceed with the bid process. The default value should be higher than // the typical block time to ensure we consider the next slot as a non-opt-in slot. timeToOptIn = blockTime * 32 } - start := time.Now() + logs, err := t.simulator.Simulate(ctx, txn.Raw) + if err != nil { + logger.Error("Failed to simulate transaction", "error", err) + return bidResult{}, fmt.Errorf("failed to simulate transaction: %w", err) + } + bidBlockNo, timeUntilNextBlock, err := t.blockTracker.NextBlockNumber() if err != nil { - t.logger.Error("Failed to get next block number", "error", err) + logger.Error("Failed to get next block number", "error", err) return bidResult{}, &errRetry{ err: fmt.Errorf("failed to get next block number: %w", err), retryAfter: time.Second, @@ -616,7 +639,7 @@ func (t *TxSender) sendBid( } if timeUntilNextBlock <= time.Second { - t.logger.Warn("Next block time is too short, skipping bid", "timeUntilNextBlock", timeUntilNextBlock) + logger.Warn("Next block time is too short, skipping bid", "timeUntilNextBlock", timeUntilNextBlock) return bidResult{}, &errRetry{ err: fmt.Errorf("next block time is too short: %s", timeUntilNextBlock), retryAfter: time.Second, @@ -633,10 +656,10 @@ func (t *TxSender) sendBid( cost, err := t.calculatePriceForNextBlock(txn, bidBlockNo, prices, optedInSlot) if err != nil { - t.logger.Error("Failed to calculate price for next block", "error", err) - if errors.Is(err, ErrTimeoutExceeded) { - t.logger.Warn("Timeout exceeded while trying to process transaction", "txnHash", txn.Hash().Hex()) - return bidResult{}, ErrTimeoutExceeded + logger.Error("Failed to calculate price for next block", "error", err) + if errors.Is(err, ErrTimeoutExceeded) || errors.Is(err, ErrMaxAttemptsPerBlockExceeded) { + // We propagate these errors as is + return bidResult{}, err } return bidResult{}, &errRetry{ err: fmt.Errorf("failed to calculate price: %w", err), @@ -648,14 +671,13 @@ func (t *TxSender) sendBid( switch txn.Type { case TxTypeRegular: if !t.store.HasBalance(ctx, txn.Sender, cost) { - t.logger.Error("Insufficient balance for sender", "sender", txn.Sender.Hex()) + logger.Error("Insufficient balance for sender") return bidResult{}, fmt.Errorf("insufficient balance for sender: %s", txn.Sender.Hex()) } case TxTypeDeposit: if txn.Value().Cmp(cost) < 0 { - t.logger.Error( + logger.Error( "Deposit amount is less than price of deposit", - "sender", txn.Sender.Hex(), "deposit", txn.Value().String(), "price", cost.String(), ) @@ -669,9 +691,8 @@ func (t *TxSender) sendBid( case TxTypeInstantBridge: costOfBridge := new(big.Int).Mul(cost, big.NewInt(2)) // 2x the price for instant bridge if txn.Value().Cmp(costOfBridge) < 0 { - t.logger.Error( + logger.Error( "Instant bridge amount is less than price of bridge", - "sender", txn.Sender.Hex(), "bridge", txn.Value().String(), "price", costOfBridge.String(), ) @@ -695,10 +716,11 @@ func (t *TxSender) sendBid( BlockNumber: uint64(bidBlockNo), RevertingTxHashes: []string{txn.Hash().Hex()}, DecayDuration: t.getBidTimeout() * 2, + Constraint: txn.Constraint, }, ) if err != nil { - t.logger.Error("Failed to place bid", "error", err) + logger.Error("Failed to place bid", "error", err) return bidResult{}, fmt.Errorf("failed to place bid: %w", err) } @@ -707,16 +729,17 @@ func (t *TxSender) sendBid( bidAmount: cost, startTime: start, timeUntillNextBlock: timeUntilNextBlock, + logs: logs, } BID_LOOP: for { select { case <-ctx.Done(): - t.logger.Info("Context cancelled while waiting for bid status") + logger.Info("Context cancelled while waiting for bid status") return bidResult{}, ctx.Err() case bidStatus, more := <-bidC: if !more { - t.logger.Info("Bid channel closed, no more bid statuses") + logger.Info("Bid channel closed, no more bid statuses") break BID_LOOP } switch bidStatus.Type { @@ -727,15 +750,15 @@ BID_LOOP: case optinbidder.BidStatusCommitment: result.commitments = append(result.commitments, bidStatus.Arg.(*bidderapiv1.Commitment)) case optinbidder.BidStatusCancelled: - t.logger.Warn("Bid context cancelled by the bidder") + logger.Warn("Bid context cancelled by the bidder") break BID_LOOP case optinbidder.BidStatusFailed: - t.logger.Error("Bid failed", "error", bidStatus.Arg) + logger.Error("Bid failed", "error", bidStatus.Arg) break BID_LOOP } } } - t.logger.Info( + logger.Info( "Bid operation complete", "noOfProviders", result.noOfProviders, "noOfCommitments", len(result.commitments), @@ -781,6 +804,8 @@ func (t *TxSender) calculatePriceForNextBlock( confidence = confidenceSecondAttempt case attempts.attempts[i].attempts > 2: confidence = confidenceSubsequentAttempts + case attempts.attempts[i].attempts > maxAttemptsPerBlock: + return nil, fmt.Errorf("%w: block %d", ErrMaxAttemptsPerBlockExceeded, bidBlockNo) } break // No need to check further attempts for the same block } @@ -826,7 +851,7 @@ func (t *TxSender) clearBlockAttemptHistory(txn *Transaction, endTime time.Time) t.logger.Info( "Clearing block attempt history for transaction", - "hash", txn.Hash().Hex(), + "transactionHash", txn.Hash().Hex(), "blockNumber", txn.BlockNumber, "blockAttempts", len(attempts.attempts), "startTime", attempts.startTime.Format(time.RFC3339), diff --git a/tools/preconf-rpc/sender/sender_test.go b/tools/preconf-rpc/sender/sender_test.go index 57f92f11f..806e91b6b 100644 --- a/tools/preconf-rpc/sender/sender_test.go +++ b/tools/preconf-rpc/sender/sender_test.go @@ -21,6 +21,7 @@ type result struct { txn *sender.Transaction commitments []*bidderapiv1.Commitment blockNumber int64 + logs []*types.Log } type mockStore struct { @@ -123,11 +124,13 @@ func (m *mockStore) StoreTransaction( ctx context.Context, txn *sender.Transaction, commitments []*bidderapiv1.Commitment, + logs []*types.Log, ) error { m.preconfirmedTxns <- result{ txn: txn, commitments: commitments, blockNumber: txn.BlockNumber, + logs: logs, } m.mu.Lock() defer m.mu.Unlock() @@ -265,6 +268,12 @@ func (m *mockNotifier) NotifyTransactionStatus(txn *sender.Transaction, attempts m.notifications = append(m.notifications, txn.Hash().Hex()) } +type mockSimulator struct{} + +func (m *mockSimulator) Simulate(ctx context.Context, rawTx string) ([]*types.Log, error) { + return []*types.Log{}, nil +} + func TestSender(t *testing.T) { t.Parallel() @@ -293,8 +302,8 @@ func TestSender(t *testing.T) { blockTracker, &mockTransferer{}, notifier, + &mockSimulator{}, big.NewInt(1), // Settlement chain ID - nil, util.NewTestLogger(os.Stdout), ) if err != nil { @@ -572,8 +581,8 @@ func TestCancelTransaction(t *testing.T) { blockTracker, &mockTransferer{}, &mockNotifier{}, + &mockSimulator{}, big.NewInt(1), // Settlement chain ID - nil, util.NewTestLogger(os.Stdout), ) if err != nil { diff --git a/tools/preconf-rpc/service/service.go b/tools/preconf-rpc/service/service.go index fe6b8e205..b9b6dd714 100644 --- a/tools/preconf-rpc/service/service.go +++ b/tools/preconf-rpc/service/service.go @@ -12,7 +12,9 @@ import ( "math/big" "net/http" "slices" + "strconv" "strings" + "sync" "time" "github.com/ethereum/go-ethereum/common" @@ -27,6 +29,7 @@ import ( "github.com/primev/mev-commit/tools/preconf-rpc/pricer" "github.com/primev/mev-commit/tools/preconf-rpc/rpcserver" "github.com/primev/mev-commit/tools/preconf-rpc/sender" + "github.com/primev/mev-commit/tools/preconf-rpc/sim" "github.com/primev/mev-commit/tools/preconf-rpc/store" "github.com/primev/mev-commit/x/accountsync" "github.com/primev/mev-commit/x/contracts/ethwrapper" @@ -67,6 +70,7 @@ type Config struct { PricerAPIKey string Webhooks []string Token string + SimulatorURL string } type Service struct { @@ -242,24 +246,7 @@ func New(config *Config) (*Service, error) { blockTrackerDone := blockTracker.Start(ctx) healthChecker.Register(health.CloseChannelHealthCheck("BlockTracker", blockTrackerDone)) - allSlots := false - providers := []common.Address{} - fastTrackFn := func(cmts []*bidderapiv1.Commitment, optedInSlot bool) bool { - if !allSlots && !optedInSlot { - return false - } - if len(providers) == 0 { - return false - } - for _, p := range providers { - if !slices.ContainsFunc(cmts, func(cmt *bidderapiv1.Commitment) bool { - return common.HexToAddress(cmt.ProviderAddress).Cmp(p) == 0 - }) { - return false - } - } - return true - } + simulator := sim.NewSimulator(config.SimulatorURL) sndr, err := sender.NewTxSender( rpcstore, @@ -268,8 +255,8 @@ func New(config *Config) (*Service, error) { blockTracker, transferer, notifier, + simulator, settlementChainID, - fastTrackFn, config.Logger.With("module", "txsender"), ) if err != nil { @@ -279,7 +266,7 @@ func New(config *Config) (*Service, error) { senderDone := sndr.Start(ctx) healthChecker.Register(health.CloseChannelHealthCheck("TxSender", senderDone)) - handlers := handlers.NewRPCMethodHandler( + rpcHandlers := handlers.NewRPCMethodHandler( config.Logger.With("module", "handlers"), bidpricer, bidderClient, @@ -291,7 +278,7 @@ func New(config *Config) (*Service, error) { l1ChainID, ) - handlers.RegisterMethods(rpcServer) + rpcHandlers.RegisterMethods(rpcServer) mux := http.NewServeMux() mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { @@ -302,10 +289,75 @@ func New(config *Config) (*Service, error) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("OK")) }) - mux.Handle("/", rpcServer) + mux.HandleFunc("/{option...}", func(w http.ResponseWriter, r *http.Request) { + options := r.PathValue("option") + + if options != "" { + splits := strings.Split(options, "/") + if len(splits) != 3 { + http.Error(w, "invalid position constraint format", http.StatusBadRequest) + return + } + constraint := new(bidderapiv1.PositionConstraint) + switch splits[0] { + case "top": + constraint.Anchor = bidderapiv1.PositionConstraint_ANCHOR_TOP + case "bottom": + constraint.Anchor = bidderapiv1.PositionConstraint_ANCHOR_BOTTOM + default: + http.Error(w, "invalid position constraint", http.StatusBadRequest) + return + } + switch splits[1] { + case "absolute": + constraint.Basis = bidderapiv1.PositionConstraint_BASIS_ABSOLUTE + case "percentile": + constraint.Basis = bidderapiv1.PositionConstraint_BASIS_PERCENTILE + case "gas_percentile": + constraint.Basis = bidderapiv1.PositionConstraint_BASIS_GAS_PERCENTILE + default: + http.Error(w, "invalid position constraint type", http.StatusBadRequest) + return + } + + value, err := strconv.Atoi(splits[2]) + if err != nil { + http.Error(w, "invalid position constraint value", http.StatusBadRequest) + return + } + constraint.Value = int32(value) + + r = r.WithContext(handlers.SetPositionConstraint(r.Context(), constraint)) + } + rpcServer.ServeHTTP(w, r) + }) + + registerAdminAPIs(mux, config.Token, sndr, rpcstore) + + srv := http.Server{ + Addr: fmt.Sprintf(":%d", config.HTTPPort), + Handler: mux, + } + + go func() { + if err := srv.ListenAndServe(); err != nil { + config.Logger.Error("failed to start HTTP server", "error", err) + } + }() + + s.closers = append(s.closers, &srv) + + return s, nil +} + +type RPCStore interface { + AddSubsidy(ctx context.Context, account common.Address, amount *big.Int) error +} + +func registerAdminAPIs(mux *http.ServeMux, token string, sndr *sender.TxSender, rpcstore RPCStore) { checkAuthorization := func(r *http.Request) error { - if config.Token == "" { + if token == "" { return errors.New("server not configured with authorization token") } @@ -320,13 +372,39 @@ func New(config *Config) (*Service, error) { return errors.New("invalid authorization header format") } - if headerToken != config.Token { + if headerToken != token { return errors.New("unauthorized: invalid token") } return nil } + var fastTrackMutex sync.RWMutex + allSlots := false + providers := []common.Address{} + + fastTrackFn := func(cmts []*bidderapiv1.Commitment, optedInSlot bool) bool { + fastTrackMutex.RLock() + defer fastTrackMutex.RUnlock() + + if !allSlots && !optedInSlot { + return false + } + if len(providers) == 0 { + return false + } + for _, p := range providers { + if !slices.ContainsFunc(cmts, func(cmt *bidderapiv1.Commitment) bool { + return common.HexToAddress(cmt.ProviderAddress).Cmp(p) == 0 + }) { + return false + } + } + return true + } + + sndr.SetFastTrackFunc(fastTrackFn) + mux.HandleFunc("POST /fast-track/enable", func(w http.ResponseWriter, r *http.Request) { if err := checkAuthorization(r); err != nil { http.Error(w, err.Error(), http.StatusUnauthorized) @@ -350,6 +428,9 @@ func New(config *Config) (*Service, error) { return } + fastTrackMutex.Lock() + defer fastTrackMutex.Unlock() + allSlots = req.AllSlots providers = make([]common.Address, 0, len(req.Providers)) for _, p := range req.Providers { @@ -449,21 +530,6 @@ func New(config *Config) (*Service, error) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("OK")) }) - - srv := http.Server{ - Addr: fmt.Sprintf(":%d", config.HTTPPort), - Handler: mux, - } - - go func() { - if err := srv.ListenAndServe(); err != nil { - config.Logger.Error("failed to start HTTP server", "error", err) - } - }() - - s.closers = append(s.closers, &srv) - - return s, nil } func (s *Service) Close() error { diff --git a/tools/preconf-rpc/sim/simulator.go b/tools/preconf-rpc/sim/simulator.go new file mode 100644 index 000000000..36bb9c198 --- /dev/null +++ b/tools/preconf-rpc/sim/simulator.go @@ -0,0 +1,211 @@ +package sim + +import ( + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "net" + "net/http" + "strings" + "time" + + "github.com/ethereum/go-ethereum/core/types" +) + +type SimCall struct { + Status string `json:"status"` + GasUsed string `json:"gasUsed"` + ReturnData string `json:"returnData"` + Logs []*types.Log `json:"logs"` + Calls []SimCall `json:"calls,omitempty"` +} + +type SimBlock struct { + Number string `json:"number"` + Hash string `json:"hash"` + BaseFeePerGas string `json:"baseFeePerGas"` + LogsBloom string `json:"logsBloom"` + Transactions []string `json:"transactions"` + Calls []SimCall `json:"calls"` +} + +type Simulator struct { + apiURL string + client *http.Client +} + +func NewSimulator(apiURL string) *Simulator { + return &Simulator{ + apiURL: apiURL, + client: &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + MaxIdleConns: 256, + MaxIdleConnsPerHost: 256, + IdleConnTimeout: 90 * time.Second, + ForceAttemptHTTP2: true, + DialContext: (&net.Dialer{ + Timeout: 5 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + TLSHandshakeTimeout: 5 * time.Second, + }, + Timeout: 15 * time.Second, + }, + } +} + +type reqBody struct { + TxRaw string `json:"raw"` + Block string `json:"block,omitempty"` +} + +func (s *Simulator) Simulate(ctx context.Context, txRaw string) ([]*types.Log, error) { + body := reqBody{ + TxRaw: txRaw, + Block: "latest", + } + bodyJSON, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + fmt.Sprintf("%s/rethsim/simulate/raw", s.apiURL), + strings.NewReader(string(bodyJSON)), + ) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + resp, err := s.client.Do(req) + if err != nil { + return nil, fmt.Errorf("do request: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("bad status %d: %s", resp.StatusCode, string(respBody)) + } + + return parseResponse(respBody) +} + +func parseResponse(body []byte) ([]*types.Log, error) { + trim := strings.TrimSpace(string(body)) + if len(trim) == 0 { + return nil, errors.New("empty response") + } + + var blk SimBlock + if strings.HasPrefix(trim, "[") { + var arr []SimBlock + if err := json.Unmarshal(body, &arr); err != nil { + return nil, fmt.Errorf("decode array: %w", err) + } + if len(arr) == 0 { + return nil, errors.New("no blocks in response") + } + blk = arr[0] + } else { + if err := json.Unmarshal(body, &blk); err != nil { + return nil, fmt.Errorf("decode object: %w", err) + } + } + + if len(blk.Calls) == 0 { + return nil, errors.New("no calls in response") + } + root := blk.Calls[0] + + // Failure → build extended error + if strings.EqualFold(root.Status, "0x0") { + reason := decodeRevert(root.ReturnData, "execution reverted") + return nil, fmt.Errorf("reverted: %s", reason) + } + + // Success → collect all logs (depth-first, execution order) + var out []*types.Log + collectLogs(&root, &out) + return out, nil +} + +func collectLogs(n *SimCall, acc *[]*types.Log) { + *acc = append(*acc, n.Logs...) + for i := range n.Calls { + collectLogs(&n.Calls[i], acc) + } +} + +func decodeRevert(dataHex string, fallback string) string { + h := extract0x(dataHex) + if h == "" || h == "0x" { + return fallback + } + if len(h) < 10 { + return fallback + } + sel := strings.ToLower(h[:10]) // 0x + 8 hex + args := "0x" + h[10:] + + switch sel { + case "0x08c379a0": // Error(string) + if s, ok := abiDecodeString(args); ok { + return s + } + return "execution reverted (Error)" + case "0x4e487b71": // Panic(uint256) + if n, ok := abiDecodeUint256(args); ok { + return fmt.Sprintf("panic(0x%x)", n) + } + return "execution reverted (Panic)" + default: + return fmt.Sprintf("reverted (selector %s)", sel) + } +} + +func extract0x(x string) string { + x = strings.TrimSpace(x) + if strings.HasPrefix(x, "0x") { + return x + } + // sometimes servers embed the hex in a quoted JSON string + var s string + if err := json.Unmarshal([]byte(x), &s); err == nil && strings.HasPrefix(s, "0x") { + return s + } + return "" +} + +func abiDecodeString(args string) (string, bool) { + b, err := hex.DecodeString(strings.TrimPrefix(args, "0x")) + if err != nil || len(b) < 64 { + return "", false + } + // length at bytes 32..64 + l := new(big.Int).SetBytes(b[32:64]).Int64() + if l < 0 || int(64+l) > len(b) { + return "", false + } + return string(b[64 : 64+l]), true +} + +func abiDecodeUint256(args string) (*big.Int, bool) { + b, err := hex.DecodeString(strings.TrimPrefix(args, "0x")) + if err != nil || len(b) < 32 { + return nil, false + } + return new(big.Int).SetBytes(b[len(b)-32:]), true +} diff --git a/tools/preconf-rpc/sim/simulator_test.go b/tools/preconf-rpc/sim/simulator_test.go new file mode 100644 index 000000000..e1c9f3efe --- /dev/null +++ b/tools/preconf-rpc/sim/simulator_test.go @@ -0,0 +1,81 @@ +package sim_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/primev/mev-commit/tools/preconf-rpc/sim" +) + +var jsonRPCResponse1 = []byte(`[{"baseFeePerGas":"0x2d04732e","blobGasUsed":"0x0","calls":[{"gasUsed":"0x5208","logs":[],"returnData":"0x","status":"0x1"}],"difficulty":"0x0","excessBlobGas":"0x100000","extraData":"0x726574682f76312e382e322f6c696e7578","gasLimit":"0x2aea540","gasUsed":"0x5208","hash":"0xaf51a6fb4ea22f5175baeaeac9fb15c55bf96f5a42974fec5cdc0e9b5e41e7a0","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","miner":"0x1f9090aae28b8a3dceadf281b0f12828e676c326","mixHash":"0x8f829a3c6bfe34dc0224d2b4bf80190fdabc184c5f56f70a5d462f4696f1309c","nonce":"0x0000000000000000","number":"0x168d7c9","parentBeaconBlockRoot":"0x0000000000000000000000000000000000000000000000000000000000000000","parentHash":"0x63eea0ad2621b94f49340c8b7ef7ead45967083894af3aab1ff7b67d9dbd7df7","receiptsRoot":"0xf78dfb743fbd92ade140711c8bbc542b5e307f0ab7984eff35d751969fe57efa","requestsHash":"0xe3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","size":"0x2af","stateRoot":"0x0000000000000000000000000000000000000000000000000000000000000000","timestamp":"0x68fb9a4b","transactions":["0xe0310102ffb7651c28cb3114f1e9137f6b6fd9ccf8326ba9657c333b0cb0ad1a"],"transactionsRoot":"0x8ae72c26cb403244bb8de6039212a55eb00bb7372c2459b0967df5c5e97a526c","uncles":[],"withdrawals":[],"withdrawalsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421"}]`) + +var jsonRPCResponse2 = []byte(`[{"baseFeePerGas":"0x2d04732e","blobGasUsed":"0x0","calls":[{"gasUsed":"0x5208","logs":[{"address":"0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee","blockHash":null,"blockNumber":"0x168d7c9","blockTimestamp":"0x68fb9a4b","data":"0x0000000000000000000000000000000000000000000000000000000000000007","logIndex":"0x0","removed":false,"topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","0x000000000000000000000000ae2885e0e7a6c5f99b93b4dbc43d206c7cf67c7e","0x000000000000000000000000ae2885e0e7a6c5f99b93b4dbc43d206c7cf67c7e"],"transactionHash":"0xe0310102ffb7651c28cb3114f1e9137f6b6fd9ccf8326ba9657c333b0cb0ad1a","transactionIndex":"0x0"}],"returnData":"0x","status":"0x1"}],"difficulty":"0x0","excessBlobGas":"0x100000","extraData":"0x726574682f76312e382e322f6c696e7578","gasLimit":"0x2aea540","gasUsed":"0x5208","hash":"0x0b99997c087f1ffd50628a2a4e3e147c0c728a5bffdb1bb2d069f1e4254ba1d3","logsBloom":"0x00000000000000001000000000000000001000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000040000000000000000000000000000000000000000000000000000000000000000002000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","miner":"0x1f9090aae28b8a3dceadf281b0f12828e676c326","mixHash":"0x71b40dd1254adf5a9834608fc6e73325eb1ddacff14ee2b3d6bfd031b678c30e","nonce":"0x0000000000000000","number":"0x168d7c9","parentBeaconBlockRoot":"0x0000000000000000000000000000000000000000000000000000000000000000","parentHash":"0x63eea0ad2621b94f49340c8b7ef7ead45967083894af3aab1ff7b67d9dbd7df7","receiptsRoot":"0x7df755b885cf2890db03b44cacb889bda231ba87a572a4cec66a06bdaff31f5d","requestsHash":"0xe3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","size":"0x2af","stateRoot":"0x0000000000000000000000000000000000000000000000000000000000000000","timestamp":"0x68fb9a4b","transactions":["0xe0310102ffb7651c28cb3114f1e9137f6b6fd9ccf8326ba9657c333b0cb0ad1a"],"transactionsRoot":"0x8ae72c26cb403244bb8de6039212a55eb00bb7372c2459b0967df5c5e97a526c","uncles":[],"withdrawals":[],"withdrawalsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421"}]`) + +var errResponse = []byte(`[{"baseFeePerGas":"0xddacfa4","blobGasUsed":"0x0","calls":[{"error":{"code":3,"message":"execution reverted"},"gasUsed":"0x194714","logs":[],"returnData":"0x064a4ec600000000000000000000000000000000000000000000000000000038af10569100000000000000000000000000000000000000000000000000000038ddca493d","status":"0x0"}],"difficulty":"0x0","excessBlobGas":"0x620000","extraData":"0x726574682f76312e382e322f6c696e7578","gasLimit":"0x2adf7c0","gasUsed":"0x194714","hash":"0xcdb8b3f1cda9f148c44e51e9f9400568fb8479120c8b2da0599207cd9b9846b5","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","miner":"0xdadb0d80178819f2319190d340ce9a924f783711","mixHash":"0x4f51b3d0ea08e01914bb86339aec2c2ab66b382711059331fbc0c2f56e0e7134","nonce":"0x0000000000000000","number":"0x168bac7","parentBeaconBlockRoot":"0x0000000000000000000000000000000000000000000000000000000000000000","parentHash":"0xb54623fcd9d11cb692091209c4feb1988dc2a9aef1c9e54ce7cee4a8332eeba7","receiptsRoot":"0x7b3251ff8a2414ac1cbcc4b091493cffeaecb6530daf2c2012e7269f07a80cab","requestsHash":"0xe3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","size":"0x1b41","stateRoot":"0x0000000000000000000000000000000000000000000000000000000000000000","timestamp":"0x68fa3a5b","transactions":["0x61d03e5e6754f7e13a2d09024a5be07a44bf1b68e5d363275fb75fa45a224918"],"transactionsRoot":"0xa255a72d9dfcb7143b10ec681f5d5804b1aa3f1f12f67715665a9d90fdcb1370","uncles":[],"withdrawals":[],"withdrawalsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421"}]`) + +func TestSimulator(t *testing.T) { + txns := map[string][]byte{ + "1234": jsonRPCResponse1, + "5678": jsonRPCResponse2, + "abcd": errResponse, + } + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/rethsim/simulate/raw": + var req map[string]interface{} + defer func() { _ = r.Body.Close() }() + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + rawTx := req["raw"].(string) + if resp, ok := txns[rawTx]; ok { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(resp) + return + } + http.Error(w, "transaction not found", http.StatusNotFound) + default: + http.NotFound(w, r) + } + }), + ) + + defer srv.Close() + + t.Logf("Test server running at %s", srv.URL) + simulator := sim.NewSimulator(srv.URL) + + t.Run("SuccessfulSimulation1", func(t *testing.T) { + result, err := simulator.Simulate(context.Background(), "1234") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(result) != 0 { + t.Fatalf("expected non-empty result") + } + }) + t.Run("SuccessfulSimulation2", func(t *testing.T) { + result, err := simulator.Simulate(context.Background(), "5678") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(result) == 0 { + t.Fatalf("expected non-empty result") + } + }) + t.Run("ErrorSimulation", func(t *testing.T) { + _, err := simulator.Simulate(context.Background(), "abcd") + if err == nil { + t.Fatalf("expected error, got none") + } + if !strings.Contains(err.Error(), "reverted") { + t.Fatalf("unexpected error") + } + }) +} diff --git a/tools/preconf-rpc/store/store.go b/tools/preconf-rpc/store/store.go index f54d47f05..cd7198666 100644 --- a/tools/preconf-rpc/store/store.go +++ b/tools/preconf-rpc/store/store.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/hex" + "encoding/json" "errors" "fmt" "math/big" @@ -30,7 +31,8 @@ CREATE TABLE IF NOT EXISTS mcTransactions ( sender TEXT, tx_type INTEGER, status TEXT, - details TEXT + details TEXT, + options BYTEA );` var commitmentsTable = ` @@ -54,6 +56,13 @@ CREATE TABLE IF NOT EXISTS subsidies ( balance NUMERIC(24, 0) );` +var simulationLogs = ` +CREATE TABLE IF NOT EXISTS simulationLogs ( + transaction_hash TEXT PRIMARY KEY, + logs TEXT, + FOREIGN KEY (transaction_hash) REFERENCES mcTransactions (hash) ON DELETE CASCADE +);` + type rpcstore struct { db *sql.DB } @@ -64,6 +73,7 @@ func New(db *sql.DB) (*rpcstore, error) { commitmentsTable, balancesTable, subsidiesTable, + simulationLogs, } { _, err := db.Exec(table) if err != nil { @@ -81,11 +91,21 @@ func (s *rpcstore) Close() error { } func (s *rpcstore) AddQueuedTransaction(ctx context.Context, tx *sender.Transaction) error { + var ( + cBuf []byte + err error + ) + if tx.Constraint != nil { + cBuf, err = proto.Marshal(tx.Constraint) + if err != nil { + return fmt.Errorf("failed to marshal transaction constraint: %w", err) + } + } insertQuery := ` - INSERT INTO mcTransactions (hash, nonce, raw_transaction, sender, tx_type, status) - VALUES ($1, $2, $3, $4, $5, $6); + INSERT INTO mcTransactions (hash, nonce, raw_transaction, sender, tx_type, status, options) + VALUES ($1, $2, $3, $4, $5, $6, $7); ` - _, err := s.db.ExecContext( + _, err = s.db.ExecContext( ctx, insertQuery, tx.Hash().Hex(), @@ -94,6 +114,7 @@ func (s *rpcstore) AddQueuedTransaction(ctx context.Context, tx *sender.Transact tx.Sender.Hex(), int(tx.Type), string(sender.TxStatusPending), + cBuf, ) if err != nil { return fmt.Errorf("failed to add queued transaction: %w", err) @@ -112,8 +133,10 @@ func parseTransactionsFromRows(rows *sql.Rows) ([]*sender.Transaction, error) { blockNum sql.NullInt64 status string details sql.NullString + options []byte + pbOption *bidderapiv1.PositionConstraint ) - err := rows.Scan(&rawTransaction, &blockNum, &senderAddress, &txType, &status, &details) + err := rows.Scan(&rawTransaction, &blockNum, &senderAddress, &txType, &status, &details, &options) if err != nil { return nil, fmt.Errorf("failed to scan row: %w", err) } @@ -125,6 +148,12 @@ func parseTransactionsFromRows(rows *sql.Rows) ([]*sender.Transaction, error) { if err := parsedTxn.UnmarshalBinary(txStr); err != nil { return nil, fmt.Errorf("failed to unmarshal transaction: %w", err) } + if len(options) > 0 { + pbOption = &bidderapiv1.PositionConstraint{} + if err := proto.Unmarshal(options, pbOption); err != nil { + return nil, fmt.Errorf("failed to unmarshal transaction options: %w", err) + } + } txn := &sender.Transaction{ Transaction: parsedTxn, Raw: rawTransaction, @@ -133,6 +162,7 @@ func parseTransactionsFromRows(rows *sql.Rows) ([]*sender.Transaction, error) { Type: sender.TxType(txType), Status: sender.TxStatus(status), Details: details.String, + Constraint: pbOption, } transactions = append(transactions, txn) } @@ -146,7 +176,7 @@ func parseTransactionsFromRows(rows *sql.Rows) ([]*sender.Transaction, error) { // GetQueuedTransactions retrieves the next pending transaction for each sender. func (s *rpcstore) GetQueuedTransactions(ctx context.Context) ([]*sender.Transaction, error) { query := ` - SELECT t1.raw_transaction, t1.block_number, t1.sender, t1.tx_type, t1.status, t1.details + SELECT t1.raw_transaction, t1.block_number, t1.sender, t1.tx_type, t1.status, t1.details, t1.options FROM mcTransactions t1 INNER JOIN ( SELECT sender, MIN(nonce) AS min_nonce @@ -180,7 +210,7 @@ func (s *rpcstore) GetQueuedTransactions(ctx context.Context) ([]*sender.Transac func (s *rpcstore) GetTransactionByHash(ctx context.Context, txnHash common.Hash) (*sender.Transaction, error) { query := ` - SELECT raw_transaction, block_number, sender, tx_type, status, details + SELECT raw_transaction, block_number, sender, tx_type, status, details, options FROM mcTransactions WHERE hash = $1; ` @@ -192,8 +222,10 @@ func (s *rpcstore) GetTransactionByHash(ctx context.Context, txnHash common.Hash status string blockNum sql.NullInt64 details sql.NullString + options []byte + pbOption *bidderapiv1.PositionConstraint ) - err := row.Scan(&rawTransaction, &blockNum, &senderAddress, &txType, &status, &details) + err := row.Scan(&rawTransaction, &blockNum, &senderAddress, &txType, &status, &details, &options) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("transaction %s not found: %w", txnHash.Hex(), ErrNotFound) @@ -208,6 +240,12 @@ func (s *rpcstore) GetTransactionByHash(ctx context.Context, txnHash common.Hash if err := parsedTxn.UnmarshalBinary(txStr); err != nil { return nil, fmt.Errorf("failed to unmarshal transaction: %w", err) } + if len(options) > 0 { + pbOption = &bidderapiv1.PositionConstraint{} + if err := proto.Unmarshal(options, pbOption); err != nil { + return nil, fmt.Errorf("failed to unmarshal transaction options: %w", err) + } + } txn := &sender.Transaction{ Transaction: parsedTxn, Raw: rawTransaction, @@ -216,6 +254,7 @@ func (s *rpcstore) GetTransactionByHash(ctx context.Context, txnHash common.Hash Type: sender.TxType(txType), Status: sender.TxStatus(status), Details: details.String, + Constraint: pbOption, } return txn, nil @@ -223,7 +262,7 @@ func (s *rpcstore) GetTransactionByHash(ctx context.Context, txnHash common.Hash func (s *rpcstore) GetTransactionsForBlock(ctx context.Context, blockNumber int64) ([]*sender.Transaction, error) { query := ` - SELECT raw_transaction, block_number, sender, tx_type, status, details + SELECT raw_transaction, block_number, sender, tx_type, status, details, options FROM mcTransactions WHERE block_number = $1 AND status = 'pre-confirmed'; ` @@ -255,6 +294,7 @@ func (s *rpcstore) StoreTransaction( ctx context.Context, txn *sender.Transaction, commitments []*bidderapiv1.Commitment, + logs []*types.Log, ) error { if txn.Status == sender.TxStatusPending { return fmt.Errorf("transaction must not be in pending status, got %s", txn.Status) @@ -309,6 +349,24 @@ func (s *rpcstore) StoreTransaction( } } + if logs != nil { + logBuf, err := json.Marshal(logs) + if err != nil { + _ = dbTxn.Rollback() + return fmt.Errorf("failed to marshal simulation logs for transaction %s: %w", txn.Hash().Hex(), err) + } + insertLogs := ` + INSERT INTO simulationLogs (transaction_hash, logs) + VALUES ($1, $2) + ON CONFLICT (transaction_hash) DO UPDATE SET logs = EXCLUDED.logs; + ` + _, err = dbTxn.ExecContext(ctx, insertLogs, txn.Hash().Hex(), string(logBuf)) + if err != nil { + _ = dbTxn.Rollback() + return fmt.Errorf("failed to insert simulation logs for transaction %s: %w", txn.Hash().Hex(), err) + } + } + if err := dbTxn.Commit(); err != nil { return fmt.Errorf("failed to commit transaction: %w", err) } @@ -316,6 +374,30 @@ func (s *rpcstore) StoreTransaction( return nil } +func (s *rpcstore) GetTransactionLogs(ctx context.Context, txnHash common.Hash) ([]*types.Log, error) { + query := ` + SELECT logs + FROM simulationLogs + WHERE transaction_hash = $1; + ` + row := s.db.QueryRowContext(ctx, query, txnHash.Hex()) + var logsData string + err := row.Scan(&logsData) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return []*types.Log{}, nil // No logs found, return empty slice + } + return nil, fmt.Errorf("failed to get logs for transaction %s: %w", txnHash.Hex(), err) + } + + var logs []*types.Log + if err := json.Unmarshal([]byte(logsData), &logs); err != nil { + return nil, fmt.Errorf("failed to unmarshal logs for transaction %s: %w", txnHash.Hex(), err) + } + + return logs, nil +} + func (s *rpcstore) GetTransactionCommitments(ctx context.Context, txnHash common.Hash) ([]*bidderapiv1.Commitment, error) { query := ` SELECT commitment_data diff --git a/tools/preconf-rpc/store/store_test.go b/tools/preconf-rpc/store/store_test.go index 6104a96bc..aca5e97fa 100644 --- a/tools/preconf-rpc/store/store_test.go +++ b/tools/preconf-rpc/store/store_test.go @@ -101,6 +101,16 @@ func TestStore(t *testing.T) { Type: sender.TxTypeRegular, Status: sender.TxStatusPending, } + txn1Logs := []*types.Log{ + { + Address: common.HexToAddress("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"), + Topics: []common.Hash{common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")}, + Data: []byte{0x01, 0x02, 0x03}, + BlockNumber: 1, + TxHash: txn1.Hash(), + TxIndex: 0, + }, + } txn2 := types.NewTransaction( 1, @@ -120,6 +130,11 @@ func TestStore(t *testing.T) { Sender: common.HexToAddress("0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"), Type: sender.TxTypeRegular, Status: sender.TxStatusPending, + Constraint: &bidderapiv1.PositionConstraint{ + Anchor: bidderapiv1.PositionConstraint_ANCHOR_TOP, + Basis: bidderapiv1.PositionConstraint_BASIS_PERCENTILE, + Value: 10, + }, } commitments := []*bidderapiv1.Commitment{ @@ -190,7 +205,7 @@ func TestStore(t *testing.T) { if len(retrievedTxns) != 1 { t.Fatalf("expected 1 queued transaction, got %d", len(retrievedTxns)) } - if diff := cmp.Diff(wrappedTxn1, retrievedTxns[0], cmpopts.IgnoreUnexported(sender.Transaction{}, types.Transaction{})); diff != "" { + if diff := cmp.Diff(wrappedTxn1, retrievedTxns[0], cmpopts.IgnoreUnexported(sender.Transaction{}, types.Transaction{}, bidderapiv1.PositionConstraint{})); diff != "" { t.Fatalf("queued transaction mismatch (-want +got):\n%s", diff) } }) @@ -199,7 +214,7 @@ func TestStore(t *testing.T) { wrappedTxn1.Status = sender.TxStatusPreConfirmed wrappedTxn1.BlockNumber = 1 - err := st.StoreTransaction(context.Background(), wrappedTxn1, commitments) + err := st.StoreTransaction(context.Background(), wrappedTxn1, commitments, txn1Logs) if err != nil { t.Errorf("failed to store preconfirmed transaction: %v", err) } @@ -212,7 +227,7 @@ func TestStore(t *testing.T) { t.Errorf("expected 2 commitments, got %d", len(commitments)) } for i, commitment := range commitments { - if diff := cmp.Diff(commitment, commitments[i], cmpopts.IgnoreUnexported(bidderapiv1.Commitment{}, types.Transaction{})); diff != "" { + if diff := cmp.Diff(commitment, commitments[i], cmpopts.IgnoreUnexported(bidderapiv1.Commitment{}, types.Transaction{}, bidderapiv1.PositionConstraint{})); diff != "" { t.Errorf("commitment mismatch (-want +got):\n%s", diff) } } @@ -224,7 +239,7 @@ func TestStore(t *testing.T) { if len(nextTxns) != 1 { t.Errorf("expected 1 queued transaction, got %d", len(nextTxns)) } - if diff := cmp.Diff(wrappedTxn2, nextTxns[0], cmpopts.IgnoreUnexported(sender.Transaction{}, types.Transaction{})); diff != "" { + if diff := cmp.Diff(wrappedTxn2, nextTxns[0], cmpopts.IgnoreUnexported(sender.Transaction{}, types.Transaction{}, bidderapiv1.PositionConstraint{})); diff != "" { t.Errorf("queued transaction mismatch (-want +got):\n%s", diff) } @@ -239,10 +254,18 @@ func TestStore(t *testing.T) { t.Errorf("transaction mismatch (-want +got):\n%s", diff) } + logs, err := st.GetTransactionLogs(context.Background(), wrappedTxn1.Hash()) + if err != nil { + t.Errorf("failed to get transaction logs: %v", err) + } + if diff := cmp.Diff(txn1Logs, logs, cmpopts.IgnoreUnexported(types.Log{})); diff != "" { + t.Errorf("transaction logs mismatch (-want +got):\n%s", diff) + } + wrappedTxn2.Status = sender.TxStatusFailed wrappedTxn2.Details = "Transaction failed due to insufficient funds" wrappedTxn2.BlockNumber = 2 - err = st.StoreTransaction(context.Background(), wrappedTxn2, nil) + err = st.StoreTransaction(context.Background(), wrappedTxn2, nil, nil) if err != nil { t.Errorf("failed to store failed transaction: %v", err) } @@ -252,7 +275,7 @@ func TestStore(t *testing.T) { t.Errorf("failed to get failed transaction by hash: %v", err) } - if diff := cmp.Diff(wrappedTxn2, failedTxn, cmpopts.IgnoreUnexported(sender.Transaction{}, types.Transaction{})); diff != "" { + if diff := cmp.Diff(wrappedTxn2, failedTxn, cmpopts.IgnoreUnexported(sender.Transaction{}, types.Transaction{}, bidderapiv1.PositionConstraint{})); diff != "" { t.Errorf("failed transaction mismatch (-want +got):\n%s", diff) } diff --git a/x/opt-in-bidder/bidder.go b/x/opt-in-bidder/bidder.go index 992020dcf..cac41c4b4 100644 --- a/x/opt-in-bidder/bidder.go +++ b/x/opt-in-bidder/bidder.go @@ -189,6 +189,7 @@ type BidOpts struct { BlockNumber uint64 RevertingTxHashes []string DecayDuration time.Duration + Constraint *bidderapiv1.PositionConstraint } var defaultBidOpts = &BidOpts{ @@ -285,6 +286,18 @@ func (b *BidderClient) Bid( bidReq.DecayEndTimestamp = time.UnixMilli(bidReq.DecayStartTimestamp).Add(12 * time.Second).UnixMilli() } + if opts.Constraint != nil { + bidReq.BidOptions = &bidderapiv1.BidOptions{ + Options: []*bidderapiv1.BidOption{ + { + Opt: &bidderapiv1.BidOption_PositionConstraint{ + PositionConstraint: opts.Constraint, + }, + }, + }, + } + } + pc, err := b.bidderClient.SendBid(ctx, bidReq) if err != nil { b.logger.Error("failed to send bid", "error", err)