diff --git a/cmd/send-blob/main.go b/cmd/send-blob/main.go index 6b2e6cf0..573a6e5a 100644 --- a/cmd/send-blob/main.go +++ b/cmd/send-blob/main.go @@ -25,7 +25,7 @@ const ( ) func main() { - rpcURL := pflag.String("rpc", "https://ethereum-sepolia-rpc.publicnode.com", "Execution-layer JSON-RPC endpoint (required)") + rpcURLs := pflag.StringSlice("rpc", []string{"https://ethereum-sepolia-rpc.publicnode.com"}, "Execution-layer JSON-RPC endpoint (required)") privKey := pflag.String("privkey", "", "Hex-encoded Ethereum private key (required)") toStr := pflag.String("to", "", "Optional destination address (defaults to sender)") numBlobs := pflag.Int("n", 1, "Number of random blobs to include") @@ -34,7 +34,7 @@ func main() { pflag.Parse() - if *rpcURL == "" || *privKey == "" || *capi == "" { + if len(*rpcURLs) == 0 || *privKey == "" || *capi == "" { pflag.Usage() return } @@ -45,10 +45,14 @@ func main() { log.Infow("starting sendblob") // 1) Init Contracts - contracts, err := web3.New([]string{*rpcURL}, *capi, 1.0) + contracts, err := web3.New(*rpcURLs, *capi, 1.0) if err != nil { log.Fatalf("init web3: %v", err) } + if err := contracts.LoadContracts(nil); err != nil { + log.Fatalf("failed to load contracts: %w", err) + } + if err := contracts.SetAccountPrivateKey(*privKey); err != nil { log.Fatalf("set privkey: %v", err) } @@ -94,14 +98,20 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - // Prepare the ABI for packing the data - processABI, err := contracts.ProcessRegistryABI() + processID := types.NewProcessID(from, types.ProcessIDVersion(uint32(contracts.ChainID), to), 1) + + data, err := contracts.ProcessRegistryABI().Pack("submitStateTransition", processID, []byte{0x1}, []byte{0x1}) if err != nil { - log.Fatal("failed to get process registry ABI: %w", err) + log.Fatalf("failed to pack data: %w", err) + } + + // Simulate tx to the contract to check if it will fail and get the root + // cause of the failure if it does + if err := contracts.SimulateProcessTransition(ctx, processID, []byte{0x1}, []byte{0x1}, sidecar); err != nil { + log.Debugw("failed to simulate state transition", "error", err, "processID", processID.String()) } - tx, err := contracts.NewEIP4844Transaction(ctx, to, processABI, "submitStateTransition", - []any{[32]byte{0x1}, []byte{0x1}, []byte{0x1}}, sidecar) + tx, err := contracts.NewEIP4844Transaction(ctx, to, data, sidecar) if err != nil { log.Fatalf("failed to build blob tx: %v", err) } diff --git a/sequencer/onchain.go b/sequencer/onchain.go index 2c3ae2c2..4c6ef582 100644 --- a/sequencer/onchain.go +++ b/sequencer/onchain.go @@ -171,16 +171,7 @@ func (s *Sequencer) pushTransitionToContract( // Simulate tx to the contract to check if it will fail and get the root // cause of the failure if it does - if err := s.contracts.SimulateContractCall( - s.ctx, - s.contracts.ContractsAddresses.ProcessRegistry, - s.contracts.ContractABIs.ProcessRegistry, - "submitStateTransition", - blobSidecar, - processID, - abiProof, - abiInputs, - ); err != nil { + if err := s.contracts.SimulateProcessTransition(s.ctx, processID, abiProof, abiInputs, blobSidecar); err != nil { log.Debugw("failed to simulate state transition", "error", err, "processID", processID.String()) @@ -281,16 +272,7 @@ func (s *Sequencer) processResultsOnChain() { "strInputs", res.Inputs.String()) // Simulate tx to the contract to check if it will fail and get the root // cause of the failure if it does - if err := s.contracts.SimulateContractCall( - s.ctx, - s.contracts.ContractsAddresses.ProcessRegistry, - s.contracts.ContractABIs.ProcessRegistry, - "setProcessResults", - nil, // No blob sidecar for regular contract calls - res.ProcessID, - abiProof, - abiInputs, - ); err != nil { + if err := s.contracts.SimulateProcessResults(s.ctx, res.ProcessID, abiProof, abiInputs); err != nil { log.Debugw("failed to simulate verified results upload", "error", err, "processID", res.ProcessID.String()) diff --git a/tests/helpers_test.go b/tests/helpers_test.go index 29507406..148d3e5b 100644 --- a/tests/helpers_test.go +++ b/tests/helpers_test.go @@ -435,28 +435,6 @@ func setupWeb3(ctx context.Context) (*web3.Contracts, func(), error) { cleanupFuncs = append(cleanupFuncs, func() { txm.Stop() }) - // Set contracts ABIs - contracts.ContractABIs = &web3.ContractABIs{} - contracts.ContractABIs.ProcessRegistry, err = contracts.ProcessRegistryABI() - if err != nil { - cleanup() // Clean up what we've done so far - return nil, nil, fmt.Errorf("failed to get process registry ABI: %w", err) - } - contracts.ContractABIs.OrganizationRegistry, err = contracts.OrganizationRegistryABI() - if err != nil { - cleanup() // Clean up what we've done so far - return nil, nil, fmt.Errorf("failed to get organization registry ABI: %w", err) - } - contracts.ContractABIs.StateTransitionZKVerifier, err = contracts.StateTransitionVerifierABI() - if err != nil { - cleanup() // Clean up what we've done so far - return nil, nil, fmt.Errorf("failed to get state transition verifier ABI: %w", err) - } - contracts.ContractABIs.ResultsZKVerifier, err = contracts.ResultsVerifierABI() - if err != nil { - cleanup() // Clean up what we've done so far - return nil, nil, fmt.Errorf("failed to get results verifier ABI: %w", err) - } // Return the contracts object and cleanup function return contracts, cleanup, nil } diff --git a/web3/blobs.go b/web3/blobs.go index bc4cc918..3760a71f 100644 --- a/web3/blobs.go +++ b/web3/blobs.go @@ -9,7 +9,6 @@ import ( "strings" "github.com/ethereum/go-ethereum" - "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" gethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/holiman/uint256" @@ -53,9 +52,7 @@ func applyGasMultiplier(baseFee *big.Int, multiplier float64) *big.Int { func (c *Contracts) NewEIP4844Transaction( ctx context.Context, to common.Address, - contractABI *abi.ABI, - method string, - args []any, + data []byte, blobsSidecar *types.BlobTxSidecar, ) (*gethtypes.Transaction, error) { // Nonce @@ -63,7 +60,7 @@ func (c *Contracts) NewEIP4844Transaction( if err != nil { return nil, err } - return c.NewEIP4844TransactionWithNonce(ctx, to, contractABI, method, args, blobsSidecar, nonce) + return c.NewEIP4844TransactionWithNonce(ctx, to, data, nonce, blobsSidecar) } // NewEIP4844TransactionWithNonce method creates and signs a new EIP-4844. It @@ -72,37 +69,22 @@ func (c *Contracts) NewEIP4844Transaction( // // Requirements: // - `to` MUST be non-nil per EIP-4844. -// - `contractABI` MUST be non-nil. // - `method` MUST be a valid method in the ABI. // - `c.signer` MUST be non-nil (private key set). func (c *Contracts) NewEIP4844TransactionWithNonce( ctx context.Context, to common.Address, - contractABI *abi.ABI, - method string, - args []any, - blobsSidecar *types.BlobTxSidecar, + data []byte, nonce uint64, + blobsSidecar *types.BlobTxSidecar, ) (*gethtypes.Transaction, error) { - if contractABI == nil { - return nil, fmt.Errorf("nil contract ABI") - } if (to == common.Address{}) { return nil, fmt.Errorf("empty to address") } - if method == "" { - return nil, fmt.Errorf("empty method") - } if c.signer == nil { return nil, fmt.Errorf("no signer defined") } - // ABI-encode call data - data, err := contractABI.Pack(method, args...) - if err != nil { - return nil, fmt.Errorf("failed to encode ABI: %w", err) - } - // Estimate execution gas, include blob hashes so any contract logic that // references them (e.g. checks) isn't under-estimated. gas, err := c.txManager.EstimateGas(ctx, ethereum.CallMsg{ @@ -112,6 +94,9 @@ func (c *Contracts) NewEIP4844TransactionWithNonce( BlobHashes: blobsSidecar.BlobHashes(), }, txmanager.DefaultGasEstimateOpts, txmanager.DefaultCancelGasFallback) if err != nil { + if reason, ok := c.DecodeError(err); ok { + return nil, fmt.Errorf("failed to estimate gas: %w (decoded: %s)", err, reason) + } return nil, fmt.Errorf("failed to estimate gas: %w", err) } diff --git a/web3/contracts.go b/web3/contracts.go index 3a821757..278a1fdb 100644 --- a/web3/contracts.go +++ b/web3/contracts.go @@ -42,6 +42,27 @@ const ( currentBlockIntervalUpdate = 5 * time.Second ) +var ( + organizationRegistryABI *abi.ABI + processRegistryABI *abi.ABI + stateTransitionZKVerifierABI *abi.ABI + resultsZKVerifierABI *abi.ABI +) + +func init() { + parseABI := func(raw string) *abi.ABI { + parsedABI, err := abi.JSON(strings.NewReader(raw)) + if err != nil { + panic(fmt.Errorf("failed to parse ABI: %w", err)) + } + return &parsedABI + } + organizationRegistryABI = parseABI(npbindings.OrganizationRegistryMetaData.ABI) + processRegistryABI = parseABI(npbindings.ProcessRegistryMetaData.ABI) + stateTransitionZKVerifierABI = parseABI(vbindings.StateTransitionVerifierGroth16MetaData.ABI) + resultsZKVerifierABI = parseABI(vbindings.ResultsVerifierGroth16MetaData.ABI) +} + // Addresses contains the addresses of the contracts deployed in the network. type Addresses struct { OrganizationRegistry common.Address @@ -279,28 +300,11 @@ func (c *Contracts) LoadContracts(addresses *Addresses) error { c.processes = process c.organizations = organizations - orgRegistryABI, err := abi.JSON(strings.NewReader(npbindings.OrganizationRegistryABI)) - if err != nil { - return fmt.Errorf("failed to parse organization registry ABI: %w", err) - } - processRegistryABI, err := abi.JSON(strings.NewReader(npbindings.ProcessRegistryABI)) - if err != nil { - return fmt.Errorf("failed to parse process registry ABI: %w", err) - } - stVerifierABI, err := abi.JSON(strings.NewReader(vbindings.StateTransitionVerifierGroth16ABI)) - if err != nil { - return fmt.Errorf("failed to parse zk verifier ABI: %w", err) - } - rVerifierABI, err := abi.JSON(strings.NewReader(vbindings.ResultsVerifierGroth16ABI)) - if err != nil { - return fmt.Errorf("failed to parse zk verifier ABI: %w", err) - } - c.ContractABIs = &ContractABIs{ - OrganizationRegistry: &orgRegistryABI, - ProcessRegistry: &processRegistryABI, - StateTransitionZKVerifier: &stVerifierABI, - ResultsZKVerifier: &rVerifierABI, + OrganizationRegistry: organizationRegistryABI, + ProcessRegistry: processRegistryABI, + StateTransitionZKVerifier: stateTransitionZKVerifierABI, + ResultsZKVerifier: resultsZKVerifierABI, } // check for blob transaction support querying the ProcessRegistry contract @@ -438,59 +442,55 @@ func (c *Contracts) authTransactOpts() (*bind.TransactOpts, error) { func (c *Contracts) SimulateContractCall( ctx context.Context, contractAddr common.Address, - contractABI *abi.ABI, - method string, + data []byte, blobsSidecar *types.BlobTxSidecar, - args ...any, ) error { - if contractABI == nil { - return fmt.Errorf("nil contract ABI") - } if (contractAddr == common.Address{}) { return fmt.Errorf("empty contract address") } - if method == "" { - return fmt.Errorf("empty method") - } if c.signer == nil { return fmt.Errorf("no signer defined") } - data, err := contractABI.Pack(method, args...) - if err != nil { - return fmt.Errorf("pack %s: %w", method, err) - } - auth, err := c.authTransactOpts() if err != nil { return fmt.Errorf("failed to create transactor: %w", err) } - auth.GasPrice, err = c.cli.SuggestGasPrice(ctx) + tipCap, err := c.cli.SuggestGasTipCap(ctx) if err != nil { - return fmt.Errorf("failed to get gas price: %w", err) + return fmt.Errorf("failed to get tip cap: %w", err) } + baseFee, err := c.cli.SuggestGasPrice(ctx) + if err != nil { + return fmt.Errorf("failed to get base fee: %w", err) + } + // Cap gas fee (baseFee * 2 + tipCap) + gasFeeCap := new(big.Int).Add(new(big.Int).Mul(baseFee, big.NewInt(2)), tipCap) call := Call{ - From: c.signer.Address(), - To: contractAddr, - Data: data, - GasPrice: (*hexutil.Big)(auth.GasPrice), - Nonce: hexutil.Uint64(auth.Nonce.Uint64()), + From: c.signer.Address(), + To: contractAddr, + Data: data, + GasTipCap: (*hexutil.Big)(tipCap), + GasFeeCap: (*hexutil.Big)(gasFeeCap), + Nonce: hexutil.Uint64(auth.Nonce.Uint64()), } if blobsSidecar != nil { call.BlobHashes = blobsSidecar.BlobHashes() call.Sidecar = blobsSidecar - gas, err := c.cli.EstimateGas(ctx, ethereum.CallMsg{ + gas, err := c.txManager.EstimateGas(ctx, ethereum.CallMsg{ From: c.signer.Address(), To: &contractAddr, - Value: nil, Data: data, BlobHashes: blobsSidecar.BlobHashes(), - }) + }, txmanager.DefaultGasEstimateOpts, txmanager.DefaultCancelGasFallback) if err != nil { + if reason, ok := c.DecodeError(err); ok { + return fmt.Errorf("failed to estimate gas: %w (decoded: %s)", err, reason) + } return fmt.Errorf("failed to estimate gas: %w", err) } call.Gas = hexutil.Uint64(gas) @@ -521,61 +521,64 @@ func (c *Contracts) SimulateContractCall( return nil } - reason, uerr := c.decodeRevert(callResult.Error.Data) - if uerr != nil { - return fmt.Errorf("call reverted; failed to unpack reason: %w", uerr) + if reason, ok := c.DecodeError(callResult.Error); ok { + return fmt.Errorf("call reverted: %w (decoded: %s)", callResult.Error, reason) } - return fmt.Errorf("call reverted: %s", reason) + return fmt.Errorf("call reverted: %w", callResult.Error) } -// decodeRevert decodes the revert reason from the given data. -func (c *Contracts) decodeRevert(data hexutil.Bytes) (string, error) { - if len(data) < 4 { - return "", fmt.Errorf("no revert data") +// DecodeError tries to decode revert reasons or custom errors from err, +// and returns true if successful. +func (c *Contracts) DecodeError(err error) (string, bool) { + if err == nil { + return "", false + } + rpcErr := rpc.ParseError(err) + if rpcErr == nil || len(rpcErr.Data) < 4 { + return "", false } - selector := data[:4] - payload := data[4:] + + errId := [4]byte{} + copy(errId[:], rpcErr.Data[:4]) // 1) Try custom errors from all loaded ABIs var decoded string - err := c.ContractABIs.forEachABI(func(_ string, a *abi.ABI) error { - for name, e := range a.Errors { - // e.ID is the 4-byte selector - if bytes.Equal(selector, e.ID.Bytes()) { - // unpack args if any - vals, uerr := e.Inputs.Unpack(payload) - if uerr != nil { - decoded = name // at least return the name - return nil - } - if len(vals) == 0 { - decoded = name - } else { - decoded = fmt.Sprintf("%s%v", name, vals) - } + abiErr := c.ContractABIs.forEachABI(func(abiName string, a *abi.ABI) error { + if abiErr, err := a.ErrorByID(errId); err == nil { + // unpack args if any + vals, err := abiErr.Inputs.Unpack(rpcErr.Data[4:]) + if err != nil || len(vals) == 0 { + decoded = fmt.Sprintf("%s %s = %s", abiName, rpcErr.Data[:4].String(), abiErr.String()) return nil } + decoded = fmt.Sprintf("%s %s = %s %+v", abiName, rpcErr.Data[:4].String(), abiErr.Name, vals) + return nil } return nil }) - if err != nil { - return "", err + if abiErr != nil { + log.Warnf("forEachABI failed with err: %s", abiErr) } if decoded != "" { - return decoded, nil + return decoded, true } // 2) Fallback to standard Error(string)/Panic(uint256) - if reason, uerr := abi.UnpackRevert(data); uerr == nil { - return reason, nil + decoded, uerr := abi.UnpackRevert(rpcErr.Data) + if uerr != nil { + log.Warnf("abi.UnpackRevert failed with err: %s", uerr) + return "", false } - return "", fmt.Errorf("unknown error selector 0x%x", selector) + return decoded, true } // forEachABI calls fn(name, abi) for each non-nil *abi.ABI field. // Stops and returns an error if fn returns an error. func (c *ContractABIs) forEachABI(fn func(fieldName string, a *abi.ABI) error) error { + if c == nil { + return fmt.Errorf("no contract ABIs") + } v := reflect.ValueOf(c).Elem() // reflect.Value of the struct t := v.Type() // reflect.Type of the struct for i := range v.NumField() { // loop fields @@ -597,40 +600,16 @@ func (c *ContractABIs) forEachABI(fn func(fieldName string, a *abi.ABI) error) e } // ProcessRegistryABI returns the ABI of the ProcessRegistry contract. -func (c *Contracts) ProcessRegistryABI() (*abi.ABI, error) { - processRegistryABI, err := abi.JSON(strings.NewReader(npbindings.ProcessRegistryABI)) - if err != nil { - return nil, fmt.Errorf("failed to parse process registry ABI: %w", err) - } - return &processRegistryABI, nil -} +func (c *Contracts) ProcessRegistryABI() *abi.ABI { return processRegistryABI } // ResultsRegistryABI returns the ABI of the ResultsRegistry contract. -func (c *Contracts) OrganizationRegistryABI() (*abi.ABI, error) { - organizationRegistryABI, err := abi.JSON(strings.NewReader(npbindings.OrganizationRegistryABI)) - if err != nil { - return nil, fmt.Errorf("failed to parse organization registry ABI: %w", err) - } - return &organizationRegistryABI, nil -} +func (c *Contracts) OrganizationRegistryABI() *abi.ABI { return organizationRegistryABI } // StateTransitionVerifierABI returns the ABI of the ZKVerifier contract. -func (c *Contracts) StateTransitionVerifierABI() (*abi.ABI, error) { - stVerifierABI, err := abi.JSON(strings.NewReader(vbindings.StateTransitionVerifierGroth16ABI)) - if err != nil { - return nil, fmt.Errorf("failed to parse state transition zk verifier ABI: %w", err) - } - return &stVerifierABI, nil -} +func (c *Contracts) StateTransitionVerifierABI() *abi.ABI { return stateTransitionZKVerifierABI } // ResultsVerifierABI returns the ABI of the ResultsVerifier contract. -func (c *Contracts) ResultsVerifierABI() (*abi.ABI, error) { - resultsVerifierABI, err := abi.JSON(strings.NewReader(vbindings.ResultsVerifierGroth16ABI)) - if err != nil { - return nil, fmt.Errorf("failed to parse results zk verifier ABI: %w", err) - } - return &resultsVerifierABI, nil -} +func (c *Contracts) ResultsVerifierABI() *abi.ABI { return resultsZKVerifierABI } func (c *Contracts) ProcessRegistryAddress() (string, error) { chainName, ok := npbindings.AvailableNetworksByID[uint32(c.ChainID)] diff --git a/web3/process.go b/web3/process.go index 0d4c2051..dfc71456 100644 --- a/web3/process.go +++ b/web3/process.go @@ -44,6 +44,9 @@ func (c *Contracts) CreateProcess(process *types.Process) (types.ProcessID, *com p.LatestStateRoot, ) if err != nil { + if reason, ok := c.DecodeError(err); ok { + return types.ProcessID{}, nil, fmt.Errorf("failed to create process: %w (decoded: %s)", err, reason) + } return types.ProcessID{}, nil, fmt.Errorf("failed to create process: %w", err) } hash := tx.Hash() @@ -105,10 +108,10 @@ func (c *Contracts) StateRoot(processID types.ProcessID) (*types.BigInt, error) func (c *Contracts) sendProcessTransition(processID types.ProcessID, proof, inputs []byte, blobsSidecar *types.BlobTxSidecar) (types.HexBytes, *common.Hash, error) { ctx, cancel := context.WithTimeout(context.Background(), web3WaitTimeout) defer cancel() - // Prepare the ABI for packing the data - processABI, err := c.ProcessRegistryABI() + + data, err := c.ProcessRegistryABI().Pack("submitStateTransition", processID, proof, inputs) if err != nil { - return nil, nil, fmt.Errorf("failed to get process registry ABI: %w", err) + return nil, nil, fmt.Errorf("failed to pack data: %w", err) } // Use transaction manager for automatic nonce management @@ -119,23 +122,12 @@ func (c *Contracts) sendProcessTransition(processID types.ProcessID, proof, inpu // Build the transaction based on whether blobs are provided switch blobsSidecar { case nil: // Regular transaction - data, err := processABI.Pack("submitStateTransition", processID, proof, inputs) - if err != nil { - return nil, fmt.Errorf("failed to pack data: %w", err) - } // No blobs so we dont not need to track sidecar, sentTx will be nil return c.txManager.BuildDynamicFeeTx(internalCtx, c.ContractsAddresses.ProcessRegistry, data, nonce) default: // Blob transaction // Store tx in sentTx for tracking sidecar later - sentTx, err = c.NewEIP4844TransactionWithNonce( - internalCtx, - c.ContractsAddresses.ProcessRegistry, - processABI, - "submitStateTransition", - []any{processID, proof, inputs}, - blobsSidecar, - nonce, - ) + sentTx, err = c.NewEIP4844TransactionWithNonce(internalCtx, c.ContractsAddresses.ProcessRegistry, + data, nonce, blobsSidecar) return sentTx, err } }) @@ -171,6 +163,9 @@ func (c *Contracts) SetProcessTransition( ) error { txID, txHash, err := c.sendProcessTransition(processID, proof, inputs, blobsSidecar) if err != nil { + if reason, ok := c.DecodeError(err); ok { + return fmt.Errorf("failed to set process transition: %w (decoded: %s)", err, reason) + } return fmt.Errorf("failed to set process transition: %w", err) } log.Infow("waiting for state transition to be mined", @@ -180,6 +175,26 @@ func (c *Contracts) SetProcessTransition( return c.txManager.WaitTxByID(txID, timeout, callback...) } +// SimulateProcessTransition simulates a submitStateTransition contract call +// using the eth_simulateV1 RPC method. If blobsSidecar is provided, +// it will simulate an EIP4844 transaction with blob data. +// If blobsSidecar is nil, it will simulate a regular contract call. +// +// NOTE: this is a temporary method to simulate contract calls it works on geth +// but not expected to work on other clients or external rpc providers. +func (c *Contracts) SimulateProcessTransition( + ctx context.Context, + processID types.ProcessID, + proof, inputs []byte, + blobsSidecar *types.BlobTxSidecar, +) error { + data, err := c.ProcessRegistryABI().Pack("submitStateTransition", processID, proof, inputs) + if err != nil { + return fmt.Errorf("pack submitStateTransition: %w", err) + } + return c.SimulateContractCall(ctx, c.ContractsAddresses.ProcessRegistry, data, blobsSidecar) +} + // sendProcessResults sets the results of the process with the given ID in the // ProcessRegistry contract. It returns the transaction ID and hash of the // results submission, or an error if the submission fails. @@ -190,13 +205,8 @@ func (c *Contracts) sendProcessResults(processID types.ProcessID, proof, inputs } ctx, cancel := context.WithTimeout(context.Background(), web3WaitTimeout) defer cancel() - // Prepare the ABI for packing the data - processABI, err := c.ProcessRegistryABI() - if err != nil { - return nil, nil, fmt.Errorf("failed to get process registry ABI: %w", err) - } // Pack the data for the setProcessResults function - data, err := processABI.Pack("setProcessResults", processID, proof, inputs) + data, err := c.ProcessRegistryABI().Pack("setProcessResults", processID, proof, inputs) if err != nil { return nil, nil, fmt.Errorf("failed to pack data: %w", err) } @@ -220,6 +230,9 @@ func (c *Contracts) SetProcessResults( ) error { txID, txHash, err := c.sendProcessResults(processID, proof, inputs) if err != nil { + if reason, ok := c.DecodeError(err); ok { + return fmt.Errorf("failed to set process results: %w (decoded: %s)", err, reason) + } return fmt.Errorf("failed to set process results: %w", err) } log.Infow("waiting for process results to be mined", @@ -229,6 +242,23 @@ func (c *Contracts) SetProcessResults( return c.txManager.WaitTxByID(txID, timeout, callback...) } +// SimulateProcessResults simulates a setProcessResults contract call +// using the eth_simulateV1 RPC method. +// +// NOTE: this is a temporary method to simulate contract calls it works on geth +// but not expected to work on other clients or external rpc providers. +func (c *Contracts) SimulateProcessResults( + ctx context.Context, + processID types.ProcessID, + proof, inputs []byte, +) error { + data, err := c.ProcessRegistryABI().Pack("setProcessResults", processID, proof, inputs) + if err != nil { + return fmt.Errorf("pack setProcessResults: %w", err) + } + return c.SimulateContractCall(ctx, c.ContractsAddresses.ProcessRegistry, data, nil) +} + // SetProcessStatus sets the status of the process with the given ID in the // ProcessRegistry contract. It returns the transaction hash of the status // update, or an error if the update fails. @@ -242,6 +272,9 @@ func (c *Contracts) SetProcessStatus(processID types.ProcessID, status types.Pro autOpts.Context = ctx tx, err := c.processes.SetProcessStatus(autOpts, processID, uint8(status)) if err != nil { + if reason, ok := c.DecodeError(err); ok { + return nil, fmt.Errorf("failed to set process status: %w (decoded: %s)", err, reason) + } return nil, fmt.Errorf("failed to set process status: %w", err) } hash := tx.Hash() @@ -261,6 +294,9 @@ func (c *Contracts) SetProcessMaxVoters(processID types.ProcessID, maxVoters *ty autOpts.Context = ctx tx, err := c.processes.SetProcessMaxVoters(autOpts, processID, maxVoters.MathBigInt()) if err != nil { + if reason, ok := c.DecodeError(err); ok { + return nil, fmt.Errorf("failed to set process max voters: %w (decoded: %s)", err, reason) + } return nil, fmt.Errorf("failed to set process max voters: %w", err) } hash := tx.Hash() @@ -287,6 +323,9 @@ func (c *Contracts) SetProcessCensus(processID types.ProcessID, census types.Cen CensusOrigin: uint8(census.CensusOrigin), }) if err != nil { + if reason, ok := c.DecodeError(err); ok { + return nil, fmt.Errorf("failed to set process census: %w (decoded: %s)", err, reason) + } return nil, fmt.Errorf("failed to set process census: %w", err) } hash := tx.Hash() diff --git a/web3/rpc/web3_client.go b/web3/rpc/web3_client.go index 081ec816..448655de 100644 --- a/web3/rpc/web3_client.go +++ b/web3/rpc/web3_client.go @@ -2,6 +2,7 @@ package rpc import ( "context" + "errors" "fmt" "math/big" "strings" @@ -9,9 +10,10 @@ import ( "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" gethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" - "github.com/ethereum/go-ethereum/rpc" + gethrpc "github.com/ethereum/go-ethereum/rpc" "github.com/vocdoni/davinci-node/log" ) @@ -27,6 +29,29 @@ var ( filterLogsTimeout = 5 * time.Second ) +// permanentErrorPatterns defines error patterns that indicate permanent +// failures that should not be retried. These are typically contract-level +// rejections that will never succeed regardless of gas price or retries. +// Add new patterns here as they are discovered and confirmed. +var permanentErrorPatterns = []string{ + "execution reverted", // Contract rejected the transaction +} + +// IsPermanentError checks if an error represents a permanent failure that +// should not be retried. +func IsPermanentError(err error) bool { + if err == nil { + return false + } + errStr := strings.ToLower(err.Error()) + for _, pattern := range permanentErrorPatterns { + if strings.Contains(errStr, pattern) { + return true + } + } + return false +} + // Client struct implements bind.ContractBackend interface for a web3 pool with // an specific chainID. It allows to interact with the blockchain using the // methods provided by the interface balancing the load between the available @@ -46,16 +71,6 @@ func (c *Client) EthClient() (*ethclient.Client, error) { return endpoint.client, nil } -// RPCClient method returns the rpc.Client for the chainID of the Client -// instance. It returns an error if the chainID is not found in the pool. -func (c *Client) RPCClient() (*rpc.Client, error) { - endpoint, err := c.w3p.Endpoint(c.chainID) - if err != nil { - return nil, fmt.Errorf("error getting endpoint for chainID %d: %w", c.chainID, err) - } - return endpoint.rpcClient, nil -} - // CodeAt method wraps the CodeAt method from the ethclient.Client for the // chainID of the Client instance. It returns an error if the chainID is not // found in the pool or if the method fails. Required by the bind.ContractBackend @@ -93,7 +108,7 @@ func (c *Client) CallSimulation(ctx context.Context, result any, simReq any, blo if err != nil { return fmt.Errorf("error getting endpoint for chainID %d: %w", c.chainID, err) } - return endpoint.rpcClient.CallContext(ctx, result, "eth_simulateV1", simReq, blockTag) + return endpoint.client.Client().CallContext(ctx, result, "eth_simulateV1", simReq, blockTag) } // EstimateGas method wraps the EstimateGas method from the ethclient.Client for @@ -275,7 +290,7 @@ func (c *Client) BlockNumber(ctx context.Context) (uint64, error) { func (c *Client) BlobBaseFee(ctx context.Context) (*big.Int, error) { res, err := c.retryAndCheckErr(func(endpoint *Web3Endpoint) (any, error) { var hexFee string - if err := endpoint.rpcClient.CallContext(ctx, &hexFee, "eth_blobBaseFee"); err != nil { + if err := endpoint.client.Client().CallContext(ctx, &hexFee, "eth_blobBaseFee"); err != nil { return nil, err } f, ok := new(big.Int).SetString(strings.TrimPrefix(hexFee, "0x"), 16) @@ -340,7 +355,20 @@ func (c *Client) retryAndCheckErr(fn func(*Web3Endpoint) (any, error)) (any, err } return res, nil } - lastErr = err + if rpcErr := ParseError(err); rpcErr != nil { + lastErr = fmt.Errorf("%w (code: %d, data: %s)", err, rpcErr.Code, rpcErr.Data) + } else { + lastErr = err + } + if IsPermanentError(err) { + log.Warnw("RPC returned permanent error, not retrying", + "error", lastErr, + "chainID", c.chainID, + "failedURI", endpoint.URI, + "endpointAttempts", endpointAttempts+1, + "retriesOnEndpoint", retry+1) + return nil, fmt.Errorf("RPC call failed with permanent error, not retrying: %w", err) + } if retry < defaultRetries-1 { time.Sleep(defaultRetrySleep) } @@ -364,3 +392,57 @@ func (c *Client) retryAndCheckErr(fn func(*Web3Endpoint) (any, error)) (any, err return nil, fmt.Errorf("all endpoints exhausted for chainID %d after %d attempts: %w", c.chainID, endpointAttempts, lastErr) } + +// RPCError is the error returned by the RPC server +type RPCError struct { + Code int `json:"code"` + Message string `json:"message"` + Data hexutil.Bytes `json:"data"` +} + +func (e *RPCError) Error() string { + return fmt.Sprintf("%s (code: %d, data: %s)", e.Message, e.Code, e.Data.String()) +} + +func (e *RPCError) ErrorCode() int { + return e.Code +} + +func (e *RPCError) ErrorData() any { + return e.Data +} + +// ParseError tries to extract Data and Code from error, +// to reconstruct a *RPCError. +func ParseError(err error) *RPCError { + if err == nil { + return nil + } + if e, ok := err.(*RPCError); ok { + return e + } + + out := &RPCError{Message: err.Error()} + + // Code (if available) + var rpcErr gethrpc.Error + if errors.As(err, &rpcErr) { + out.Code = rpcErr.ErrorCode() + out.Message = rpcErr.Error() + } + + // Data (if available) + var dataErr gethrpc.DataError + if errors.As(err, &dataErr) { + switch v := dataErr.ErrorData().(type) { + case []byte: + out.Data = hexutil.Bytes(v) + case string: + if b, derr := hexutil.Decode(v); derr == nil { + out.Data = hexutil.Bytes(b) + } + } + } + + return out +} diff --git a/web3/rpc/web3_iter.go b/web3/rpc/web3_iter.go index 1ff75715..7c348c14 100644 --- a/web3/rpc/web3_iter.go +++ b/web3/rpc/web3_iter.go @@ -6,7 +6,6 @@ import ( "time" "github.com/ethereum/go-ethereum/ethclient" - "github.com/ethereum/go-ethereum/rpc" ) const ( @@ -22,7 +21,6 @@ type Web3Endpoint struct { URI string IsArchive bool client *ethclient.Client - rpcClient *rpc.Client disabledAt time.Time // When this endpoint was disabled (zero if never disabled) } diff --git a/web3/rpc/web3_pool.go b/web3/rpc/web3_pool.go index 7fead0d1..285e6181 100644 --- a/web3/rpc/web3_pool.go +++ b/web3/rpc/web3_pool.go @@ -24,7 +24,6 @@ import ( "time" "github.com/ethereum/go-ethereum/ethclient" - "github.com/ethereum/go-ethereum/rpc" "github.com/vocdoni/davinci-node/log" ) @@ -92,11 +91,7 @@ func (nm *Web3Pool) AddEndpoint(uri string) (uint64, error) { if err != nil { return 0, fmt.Errorf("error dialing web3 provider uri '%s': %w", uri, err) } - // init the rpc client - rpcClient, err := connectNodeRawRPC(ctx, uri) - if err != nil { - return 0, fmt.Errorf("error dialing rpc provider uri '%s': %w", uri, err) - } + // get the chainID from the web3 endpoint bChainID, err := client.ChainID(ctx) if err != nil { @@ -114,7 +109,6 @@ func (nm *Web3Pool) AddEndpoint(uri string) (uint64, error) { ChainID: chainID, URI: uri, client: client, - rpcClient: rpcClient, IsArchive: isArchive, } if _, ok := nm.endpoints[chainID]; !ok { @@ -247,24 +241,11 @@ func connectNodeEthereumAPI(ctx context.Context, uri string) (client *ethclient. if client, err = ethclient.DialContext(ctx, uri); err != nil { continue } - return client, err + return client, nil } return nil, fmt.Errorf("error dialing web3 provider uri '%s': %w", uri, err) } -// connectNodeRawRPC method returns a new *rpc.Client instance for the URI provided. -// It retries to connect to the rpc provider if it fails, up to the -// DefaultMaxWeb3ClientRetries times. -func connectNodeRawRPC(ctx context.Context, uri string) (client *rpc.Client, err error) { - for range DefaultMaxWeb3ClientRetries { - if client, err = rpc.DialContext(ctx, uri); err != nil { - continue - } - return client, err - } - return nil, fmt.Errorf("error dialing rpc provider uri '%s': %w", uri, err) -} - // isArchiveNode method returns true if the web3 client is an archive node. To // determine if the client is an archive node, checks the transactions of the // block 1 of the chain. If client finds transactions, it is an archive node. If diff --git a/web3/txmanager/txgas.go b/web3/txmanager/txgas.go index 635f7f47..18b802c5 100644 --- a/web3/txmanager/txgas.go +++ b/web3/txmanager/txgas.go @@ -28,8 +28,6 @@ type GasEstimateOpts struct { MinGas uint64 // minimum possible gas limit (default 21,000) MaxGas uint64 // maximum possible gas limit (default 5,000,000) SafetyBps int // safety margin in basis points (default +10%) - Retries int // retry count for RPC errors (default 5) - Backoff time.Duration // delay between retries (default 250ms) Timeout time.Duration // timeout for each estimation call (default 20s) Fallback uint64 // final fallback gas (default 300,000) } @@ -42,8 +40,6 @@ var DefaultGasEstimateOpts = &GasEstimateOpts{ MinGas: 21_000, MaxGas: 5_000_000, SafetyBps: 1000, - Retries: 5, - Backoff: 250 * time.Millisecond, Timeout: DefaultEstimateGasTimeout, Fallback: DefaultGasFallback, } @@ -60,12 +56,6 @@ func (o *GasEstimateOpts) validate() { if o.SafetyBps == 0 { o.SafetyBps = DefaultGasEstimateOpts.SafetyBps } - if o.Retries == 0 { - o.Retries = DefaultGasEstimateOpts.Retries - } - if o.Backoff == 0 { - o.Backoff = DefaultGasEstimateOpts.Backoff - } if o.Timeout == 0 { o.Timeout = DefaultGasEstimateOpts.Timeout } @@ -97,22 +87,14 @@ func (tm *TxManager) EstimateGas( defer cancel() // Ensure fee caps exist for dynamic fee calls if msg.GasFeeCap == nil || msg.GasTipCap == nil { - // Get tip cap with retries - var tipCap *big.Int - if err := retryFn(opts.Retries, opts.Backoff, func() error { - var err error - tipCap, err = tm.cli.SuggestGasTipCap(internalCtx) - return err - }); err != nil { + // Get tip cap + tipCap, err := tm.cli.SuggestGasTipCap(internalCtx) + if err != nil { log.Warnw("failed to get tip cap", "error", err) } - // Get base fee with retries - var baseFee *big.Int - if err := retryFn(opts.Retries, opts.Backoff, func() error { - var err error - baseFee, err = tm.cli.SuggestGasPrice(internalCtx) - return err - }); err != nil { + // Get base fee + baseFee, err := tm.cli.SuggestGasPrice(internalCtx) + if err != nil { log.Warnw("failed to get base fee", "error", err) } // Set fee caps if we obtained them @@ -122,19 +104,12 @@ func (tm *TxManager) EstimateGas( } } - var gas uint64 - retryErr := retryFn(opts.Retries, opts.Backoff, func() error { - var err error - gas, err = tm.cli.EstimateGas(internalCtx, msg) - return err - }) - if retryErr == nil { + if gas, err := tm.cli.EstimateGas(internalCtx, msg); err == nil { return tm.applySafetyMargin(gas, floorGasLimit, opts), nil - } else if isPermanentError(retryErr) { - return 0, fmt.Errorf("estimateGas failed with permanent error, not retrying: %w", retryErr) + } else { + log.Warnw("estimateGas failed, falling back to binary search", "error", err) } - log.Warnw("estimateGas failed, falling back to binary search", "error", retryErr) // Try a lightweight binary search with eth_call ethcli, err := tm.cli.EthClient() if err == nil { @@ -241,19 +216,3 @@ func gasKey(msg ethereum.CallMsg) string { h.Write(msg.Data) return fmt.Sprintf("%x", h.Sum(nil)) } - -// retryFn is a helper function that retries a given function a specified number -// of times with a sleep duration between attempts. -func retryFn(attempts int, sleep time.Duration, fn func() error) error { - if err := fn(); err == nil { - return nil - } - var err error - for range attempts { - time.Sleep(sleep) - if err = fn(); err == nil { - return nil - } - } - return err -} diff --git a/web3/txmanager/txsend.go b/web3/txmanager/txsend.go index ed821bf2..849364e0 100644 --- a/web3/txmanager/txsend.go +++ b/web3/txmanager/txsend.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "math/big" - "strings" "time" "github.com/ethereum/go-ethereum/common" @@ -12,31 +11,9 @@ import ( "github.com/vocdoni/davinci-node/log" "github.com/vocdoni/davinci-node/types" "github.com/vocdoni/davinci-node/util" + "github.com/vocdoni/davinci-node/web3/rpc" ) -// permanentErrorPatterns defines error patterns that indicate permanent -// failures that should not be retried. These are typically contract-level -// rejections that will never succeed regardless of gas price or retries. -// Add new patterns here as they are discovered and confirmed. -var permanentErrorPatterns = []string{ - "execution reverted", // Contract rejected the transaction -} - -// isPermanentError checks if an error represents a permanent failure that -// should not be retried. -func isPermanentError(err error) bool { - if err == nil { - return false - } - errStr := strings.ToLower(err.Error()) - for _, pattern := range permanentErrorPatterns { - if strings.Contains(errStr, pattern) { - return true - } - } - return false -} - // SendTx sends a transaction with automatic fallback and recovery mechanisms. // It accepts a transaction builder function that takes a nonce and returns a // signed transaction. If a nonce mismatch is detected, it attempts to recover @@ -212,7 +189,7 @@ func (tm *TxManager) handleStuckTxs(ctx context.Context) error { // It returns an error if the operation fails. func (tm *TxManager) speedUpTx(ctx context.Context, ptx *PendingTransaction) error { // Check if last error was permanent - no point in retrying - if isPermanentError(ptx.LastError) { + if rpc.IsPermanentError(ptx.LastError) { log.Warnw("transaction failed with permanent error, not retrying", "id", fmt.Sprintf("%x", ptx.ID), "nonce", ptx.Nonce, @@ -406,7 +383,7 @@ func (tm *TxManager) recoverTxFromNonceGap( log.Warnw("failed to send transaction after nonce recovery", "error", sendErr, "attempt", attempt+1) - if isPermanentError(err) { + if rpc.IsPermanentError(err) { return nil, fmt.Errorf("permanent error sending transaction after nonce recovery: %w", sendErr) } continue diff --git a/web3/types.go b/web3/types.go index 1640bb2d..a2bd7bf7 100644 --- a/web3/types.go +++ b/web3/types.go @@ -10,6 +10,7 @@ import ( gethtypes "github.com/ethereum/go-ethereum/core/types" npbindings "github.com/vocdoni/davinci-contracts/golang-types" "github.com/vocdoni/davinci-node/types" + "github.com/vocdoni/davinci-node/web3/rpc" ) // SimulationRequest is the top‐level payload for eth_simulateV1 @@ -42,17 +43,17 @@ type StateOverride struct { // Call is a single call to be executed in the simulated block type Call struct { - From common.Address `json:"from,omitempty"` - To common.Address `json:"to,omitempty"` - Gas hexutil.Uint64 `json:"gas,omitempty"` - GasPrice *hexutil.Big `json:"gasPrice,omitempty"` - MaxFeePerGas *hexutil.Big `json:"maxFeePerGas,omitempty"` - MaxPriorityFeePerGas *hexutil.Big `json:"maxPriorityFeePerGas,omitempty"` - Value *hexutil.Big `json:"value,omitempty"` - Data hexutil.Bytes `json:"data,omitempty"` - Nonce hexutil.Uint64 `json:"nonce,omitempty"` - BlobHashes []common.Hash `json:"blobHashes,omitempty"` - Sidecar *types.BlobTxSidecar `json:"sidecar,omitempty"` + From common.Address `json:"from,omitempty"` + To common.Address `json:"to,omitempty"` + Gas hexutil.Uint64 `json:"gas,omitempty"` + GasPrice *hexutil.Big `json:"gasPrice,omitempty"` + GasFeeCap *hexutil.Big `json:"maxFeePerGas,omitempty"` // a.k.a. maxFeePerGas + GasTipCap *hexutil.Big `json:"maxPriorityFeePerGas,omitempty"` // a.k.a. maxPriorityFeePerGas + Value *hexutil.Big `json:"value,omitempty"` + Data hexutil.Bytes `json:"data,omitempty"` + Nonce hexutil.Uint64 `json:"nonce,omitempty"` + BlobHashes []common.Hash `json:"blobHashes,omitempty"` + Sidecar *types.BlobTxSidecar `json:"sidecar,omitempty"` } // SimulatedBlock is the result of a simulated block @@ -68,14 +69,7 @@ type CallResult struct { ReturnData hexutil.Bytes `json:"returnData"` GasUsed hexutil.Uint64 `json:"gasUsed"` Logs []gethtypes.Log `json:"logs"` - Error *RPCError `json:"error,omitempty"` -} - -// RPCError is the error returned by the RPC server -type RPCError struct { - Code int `json:"code"` - Message string `json:"message"` - Data hexutil.Bytes `json:"data"` + Error *rpc.RPCError `json:"error,omitempty"` } // contractProcess2Process converts a contractProcess to a types.Process