diff --git a/arbnode/dataposter/data_poster.go b/arbnode/dataposter/data_poster.go index c26e7ab3b6..416ebf7253 100644 --- a/arbnode/dataposter/data_poster.go +++ b/arbnode/dataposter/data_poster.go @@ -469,13 +469,10 @@ func (p *DataPoster) evalMaxFeeCapExpr(backlogOfBatches uint64, elapsed time.Dur var big4 = big.NewInt(4) // The dataPosterBacklog argument should *not* include extraBacklog (it's added in in this function) -func (p *DataPoster) feeAndTipCaps(ctx context.Context, nonce uint64, gasLimit uint64, numBlobs uint64, lastTx *types.Transaction, dataCreatedAt time.Time, dataPosterBacklog uint64) (*big.Int, *big.Int, *big.Int, error) { +func (p *DataPoster) feeAndTipCaps(ctx context.Context, nonce uint64, gasLimit uint64, numBlobs uint64, lastTx *types.Transaction, dataCreatedAt time.Time, dataPosterBacklog uint64, latestHeader *types.Header) (*big.Int, *big.Int, *big.Int, error) { config := p.config() dataPosterBacklog += p.extraBacklog() - latestHeader, err := p.headerReader.LastHeader(ctx) - if err != nil { - return nil, nil, nil, err - } + if latestHeader.BaseFee == nil { return nil, nil, nil, fmt.Errorf("latest parent chain block %v missing BaseFee (either the parent chain does not have EIP-1559 or the parent chain node is not synced)", latestHeader.Number) } @@ -583,13 +580,8 @@ func (p *DataPoster) feeAndTipCaps(ctx context.Context, nonce uint64, gasLimit u blobGasUsed := params.BlobTxBlobGasPerBlob * numBlobs currentBlobCost := arbmath.BigMulByUint(currentBlobFee, blobGasUsed) currentNonBlobCost := arbmath.BigMulByUint(currentNonBlobFee, gasLimit) - currentTotalCost := arbmath.BigAdd(currentBlobCost, currentNonBlobCost) - if config.MaxFeeBidMultipleBips > 0 { - targetMaxCost = arbmath.BigMin(targetMaxCost, arbmath.BigMulByBips(currentTotalCost, config.MaxFeeBidMultipleBips)) - } - newBlobFeeCap := arbmath.BigMul(targetMaxCost, currentBlobFee) - newBlobFeeCap.Div(newBlobFeeCap, currentTotalCost) + newBlobFeeCap.Div(newBlobFeeCap, arbmath.BigAdd(currentBlobCost, currentNonBlobCost)) if lastTx != nil && lastTx.BlobGasFeeCap() != nil { newBlobFeeCap = arbmath.BigMax(newBlobFeeCap, arbmath.BigMulByBips(lastTx.BlobGasFeeCap(), minRbfIncrease)) } @@ -606,6 +598,20 @@ func (p *DataPoster) feeAndTipCaps(ctx context.Context, nonce uint64, gasLimit u newBlobFeeCap = arbmath.BigDivByUint(newBlobCost, blobGasUsed) } + if config.MaxFeeBidMultipleBips > 0 { + // Limit the fee caps to be no greater than max(MaxFeeBidMultipleBips, minRbf) + maxNonBlobFee := arbmath.BigMulByBips(currentNonBlobFee, config.MaxFeeBidMultipleBips) + if lastTx != nil { + maxNonBlobFee = arbmath.BigMax(maxNonBlobFee, arbmath.BigMulByBips(lastTx.GasFeeCap(), minRbfIncrease)) + } + maxBlobFee := arbmath.BigMulByBips(currentBlobFee, config.MaxFeeBidMultipleBips) + if lastTx != nil && lastTx.BlobGasFeeCap() != nil { + maxBlobFee = arbmath.BigMax(maxBlobFee, arbmath.BigMulByBips(lastTx.BlobGasFeeCap(), minRbfIncrease)) + } + newBaseFeeCap = arbmath.BigMin(newBaseFeeCap, maxNonBlobFee) + newBlobFeeCap = arbmath.BigMin(newBlobFeeCap, maxBlobFee) + } + if arbmath.BigGreaterThan(newTipCap, newBaseFeeCap) { log.Info( "reducing new tip cap to new basefee cap", @@ -683,7 +689,12 @@ func (p *DataPoster) PostTransaction(ctx context.Context, dataCreatedAt time.Tim return nil, fmt.Errorf("failed to update data poster balance: %w", err) } - feeCap, tipCap, blobFeeCap, err := p.feeAndTipCaps(ctx, nonce, gasLimit, uint64(len(kzgBlobs)), nil, dataCreatedAt, 0) + latestHeader, err := p.headerReader.LastHeader(ctx) + if err != nil { + return nil, err + } + + feeCap, tipCap, blobFeeCap, err := p.feeAndTipCaps(ctx, nonce, gasLimit, uint64(len(kzgBlobs)), nil, dataCreatedAt, 0, latestHeader) if err != nil { return nil, err } @@ -868,7 +879,12 @@ func updateGasCaps(tx *types.Transaction, newFeeCap, newTipCap, newBlobFeeCap *b // The mutex must be held by the caller. func (p *DataPoster) replaceTx(ctx context.Context, prevTx *storage.QueuedTransaction, backlogWeight uint64) error { - newFeeCap, newTipCap, newBlobFeeCap, err := p.feeAndTipCaps(ctx, prevTx.FullTx.Nonce(), prevTx.FullTx.Gas(), uint64(len(prevTx.FullTx.BlobHashes())), prevTx.FullTx, prevTx.Created, backlogWeight) + latestHeader, err := p.headerReader.LastHeader(ctx) + if err != nil { + return err + } + + newFeeCap, newTipCap, newBlobFeeCap, err := p.feeAndTipCaps(ctx, prevTx.FullTx.Nonce(), prevTx.FullTx.Gas(), uint64(len(prevTx.FullTx.BlobHashes())), prevTx.FullTx, prevTx.Created, backlogWeight, latestHeader) if err != nil { return err } diff --git a/arbnode/dataposter/dataposter_test.go b/arbnode/dataposter/dataposter_test.go index 06e3144ed1..a8e2e110a0 100644 --- a/arbnode/dataposter/dataposter_test.go +++ b/arbnode/dataposter/dataposter_test.go @@ -9,13 +9,18 @@ import ( "time" "github.com/Knetic/govaluate" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/accounts/abi/bind/backends" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/params" + "github.com/ethereum/go-ethereum/rpc" "github.com/google/go-cmp/cmp" "github.com/holiman/uint256" "github.com/offchainlabs/nitro/arbnode/dataposter/externalsigner" "github.com/offchainlabs/nitro/arbnode/dataposter/externalsignertest" + "github.com/offchainlabs/nitro/util/arbmath" ) func TestParseReplacementTimes(t *testing.T) { @@ -187,3 +192,293 @@ func TestMaxFeeCapFormulaCalculation(t *testing.T) { t.Fatalf("Unexpected result. Got: %d, want: >0", result) } } + +type stubL1Client struct { + senderNonce uint64 + suggestedGasTipCap *big.Int + + // Define most of the required methods that aren't used by feeAndTipCaps + backends.SimulatedBackend +} + +func (c *stubL1Client) NonceAt(ctx context.Context, account common.Address, blockNumber *big.Int) (uint64, error) { + return c.senderNonce, nil +} + +func (c *stubL1Client) SuggestGasTipCap(ctx context.Context) (*big.Int, error) { + return c.suggestedGasTipCap, nil +} + +// Not used but we need to define +func (c *stubL1Client) BlockNumber(ctx context.Context) (uint64, error) { + return 0, nil +} + +func (c *stubL1Client) CallContractAtHash(ctx context.Context, msg ethereum.CallMsg, blockHash common.Hash) ([]byte, error) { + return []byte{}, nil +} + +func (c *stubL1Client) ChainID(ctx context.Context) (*big.Int, error) { + return nil, nil +} + +func (c *stubL1Client) Client() rpc.ClientInterface { + return nil +} + +func (c *stubL1Client) TransactionSender(ctx context.Context, tx *types.Transaction, block common.Hash, index uint) (common.Address, error) { + return common.Address{}, nil +} + +func TestFeeAndTipCaps_EnoughBalance_NoBacklog_NoUnconfirmed_BlobTx(t *testing.T) { + conf := func() *DataPosterConfig { + // Set only the fields that are used by feeAndTipCaps + // Start with defaults, maybe change for test. + return &DataPosterConfig{ + MaxMempoolTransactions: 18, + MaxMempoolWeight: 18, + MinTipCapGwei: 0.05, + MinBlobTxTipCapGwei: 1, + MaxTipCapGwei: 5, + MaxBlobTxTipCapGwei: 10, + MaxFeeBidMultipleBips: arbmath.OneInBips * 10, + AllocateMempoolBalance: true, + + UrgencyGwei: 2., + ElapsedTimeBase: 10 * time.Minute, + ElapsedTimeImportance: 10, + TargetPriceGwei: 60., + } + } + expression, err := govaluate.NewEvaluableExpression(DefaultDataPosterConfig.MaxFeeCapFormula) + if err != nil { + t.Fatalf("error creating govaluate evaluable expression: %v", err) + } + + p := DataPoster{ + config: conf, + extraBacklog: func() uint64 { return 0 }, + balance: big.NewInt(0).Mul(big.NewInt(params.Ether), big.NewInt(10)), + usingNoOpStorage: false, + client: &stubL1Client{ + senderNonce: 1, + suggestedGasTipCap: big.NewInt(2 * params.GWei), + }, + auth: &bind.TransactOpts{ + From: common.Address{}, + }, + maxFeeCapExpression: expression, + } + + ctx := context.Background() + var nonce uint64 = 1 + var gasLimit uint64 = 300_000 // reasonable upper bound for mainnet blob batches + var numBlobs uint64 = 6 + var lastTx *types.Transaction // PostTransaction leaves this nil, used when replacing + dataCreatedAt := time.Now() + var dataPosterBacklog uint64 = 0 // Zero backlog for PostTransaction + var blobGasUsed uint64 = 0xc0000 // 6 blobs of gas + var excessBlobGas uint64 = 0 // typical current mainnet conditions + latestHeader := types.Header{ + Number: big.NewInt(1), + BaseFee: big.NewInt(1_000_000_000), + BlobGasUsed: &blobGasUsed, + ExcessBlobGas: &excessBlobGas, + } + + newGasFeeCap, newTipCap, newBlobFeeCap, err := p.feeAndTipCaps(ctx, nonce, gasLimit, numBlobs, lastTx, dataCreatedAt, dataPosterBacklog, &latestHeader) + if err != nil { + t.Fatalf("%s", err) + } + + // There is no backlog and almost no time elapses since the batch data was + // created to when it was posted so the maxNormalizedFeeCap is ~60.01 gwei. + // This is multiplied with the normalizedGas to get targetMaxCost. + // This is greatly in excess of currentTotalCost * MaxFeeBidMultipleBips, + // so targetMaxCost is reduced to the current base fee + suggested tip cap + + // current blob fee multipled by MaxFeeBidMultipleBips (factor of 10). + // The blob and non blob factors are then proportionally split out and so + // the newGasFeeCap is set to (current base fee + suggested tip cap) * 10 + // and newBlobFeeCap is set to current blob gas base fee (1 wei + // since there is no excess blob gas) * 10. + expectedGasFeeCap := big.NewInt(30 * params.GWei) + expectedBlobFeeCap := big.NewInt(10) + if !arbmath.BigEquals(expectedGasFeeCap, newGasFeeCap) { + t.Fatalf("feeAndTipCaps didn't return expected gas fee cap. Was: %d, expected: %d", expectedGasFeeCap, newGasFeeCap) + } + if !arbmath.BigEquals(expectedBlobFeeCap, newBlobFeeCap) { + t.Fatalf("feeAndTipCaps didn't return expected blob gas fee cap. Was: %d, expected: %d", expectedBlobFeeCap, newBlobFeeCap) + } + + // 2 gwei is the amount suggested by the L1 client, so that is the value + // returned because it doesn't exceed the configured bounds, there is no + // lastTx to scale against with rbf, and it is not bigger than the computed + // gasFeeCap. + expectedTipCap := big.NewInt(2 * params.GWei) + if !arbmath.BigEquals(expectedTipCap, newTipCap) { + t.Fatalf("feeAndTipCaps didn't return expected tip cap. Was: %d, expected: %d", expectedTipCap, newTipCap) + } + + lastBlobTx := &types.BlobTx{} + err = updateTxDataGasCaps(lastBlobTx, newGasFeeCap, newTipCap, newBlobFeeCap) + if err != nil { + t.Fatal(err) + } + lastTx = types.NewTx(lastBlobTx) + // Make creation time go backwards so elapsed time increases + retconnedCreationTime := dataCreatedAt.Add(-time.Minute) + // Base fee needs to have increased to simulate conditions to not include prev tx + latestHeader = types.Header{ + Number: big.NewInt(2), + BaseFee: big.NewInt(32_000_000_000), + BlobGasUsed: &blobGasUsed, + ExcessBlobGas: &excessBlobGas, + } + + newGasFeeCap, newTipCap, newBlobFeeCap, err = p.feeAndTipCaps(ctx, nonce, gasLimit, numBlobs, lastTx, retconnedCreationTime, dataPosterBacklog, &latestHeader) + _, _, _, _ = newGasFeeCap, newTipCap, newBlobFeeCap, err + /* + // I think we expect an increase by *2 due to rbf rules for blob txs, + // currently appears to be broken since the increase exceeds the + // current cost (based on current basefees and tip) * config.MaxFeeBidMultipleBips + // since the previous attempt to send the tx was already using the current cost scaled by + // the multiple (* 10 bips). + expectedGasFeeCap = expectedGasFeeCap.Mul(expectedGasFeeCap, big.NewInt(2)) + expectedBlobFeeCap = expectedBlobFeeCap.Mul(expectedBlobFeeCap, big.NewInt(2)) + expectedTipCap = expectedTipCap.Mul(expectedTipCap, big.NewInt(2)) + + t.Log("newGasFeeCap", newGasFeeCap, "newTipCap", newTipCap, "newBlobFeeCap", newBlobFeeCap, "err", err) + if !arbmath.BigEquals(expectedGasFeeCap, newGasFeeCap) { + t.Fatalf("feeAndTipCaps didn't return expected gas fee cap. Was: %d, expected: %d", expectedGasFeeCap, newGasFeeCap) + } + if !arbmath.BigEquals(expectedBlobFeeCap, newBlobFeeCap) { + t.Fatalf("feeAndTipCaps didn't return expected blob gas fee cap. Was: %d, expected: %d", expectedBlobFeeCap, newBlobFeeCap) + } + if !arbmath.BigEquals(expectedTipCap, newTipCap) { + t.Fatalf("feeAndTipCaps didn't return expected tip cap. Was: %d, expected: %d", expectedTipCap, newTipCap) + } + */ + +} + +func TestFeeAndTipCaps_RBF_RisingBlobFee_FallingBaseFee(t *testing.T) { + conf := func() *DataPosterConfig { + // Set only the fields that are used by feeAndTipCaps + // Start with defaults, maybe change for test. + return &DataPosterConfig{ + MaxMempoolTransactions: 18, + MaxMempoolWeight: 18, + MinTipCapGwei: 0.05, + MinBlobTxTipCapGwei: 1, + MaxTipCapGwei: 5, + MaxBlobTxTipCapGwei: 10, + MaxFeeBidMultipleBips: arbmath.OneInBips * 10, + AllocateMempoolBalance: true, + + UrgencyGwei: 2., + ElapsedTimeBase: 10 * time.Minute, + ElapsedTimeImportance: 10, + TargetPriceGwei: 60., + } + } + expression, err := govaluate.NewEvaluableExpression(DefaultDataPosterConfig.MaxFeeCapFormula) + if err != nil { + t.Fatalf("error creating govaluate evaluable expression: %v", err) + } + + p := DataPoster{ + config: conf, + extraBacklog: func() uint64 { return 0 }, + balance: big.NewInt(0).Mul(big.NewInt(params.Ether), big.NewInt(10)), + usingNoOpStorage: false, + client: &stubL1Client{ + senderNonce: 1, + suggestedGasTipCap: big.NewInt(2 * params.GWei), + }, + auth: &bind.TransactOpts{ + From: common.Address{}, + }, + maxFeeCapExpression: expression, + } + + ctx := context.Background() + var nonce uint64 = 1 + var gasLimit uint64 = 300_000 // reasonable upper bound for mainnet blob batches + var numBlobs uint64 = 6 + var lastTx *types.Transaction // PostTransaction leaves this nil, used when replacing + dataCreatedAt := time.Now() + var dataPosterBacklog uint64 = 0 // Zero backlog for PostTransaction + var blobGasUsed uint64 = 0xc0000 // 6 blobs of gas + var excessBlobGas uint64 = 0 // typical current mainnet conditions + latestHeader := types.Header{ + Number: big.NewInt(1), + BaseFee: big.NewInt(1_000_000_000), + BlobGasUsed: &blobGasUsed, + ExcessBlobGas: &excessBlobGas, + } + + newGasFeeCap, newTipCap, newBlobFeeCap, err := p.feeAndTipCaps(ctx, nonce, gasLimit, numBlobs, lastTx, dataCreatedAt, dataPosterBacklog, &latestHeader) + if err != nil { + t.Fatalf("%s", err) + } + + // There is no backlog and almost no time elapses since the batch data was + // created to when it was posted so the maxNormalizedFeeCap is ~60.01 gwei. + // This is multiplied with the normalizedGas to get targetMaxCost. + // This is greatly in excess of currentTotalCost * MaxFeeBidMultipleBips, + // so targetMaxCost is reduced to the current base fee + suggested tip cap + + // current blob fee multipled by MaxFeeBidMultipleBips (factor of 10). + // The blob and non blob factors are then proportionally split out and so + // the newGasFeeCap is set to (current base fee + suggested tip cap) * 10 + // and newBlobFeeCap is set to current blob gas base fee (1 wei + // since there is no excess blob gas) * 10. + expectedGasFeeCap := big.NewInt(30 * params.GWei) + expectedBlobFeeCap := big.NewInt(10) + if !arbmath.BigEquals(expectedGasFeeCap, newGasFeeCap) { + t.Fatalf("feeAndTipCaps didn't return expected gas fee cap. Was: %d, expected: %d", expectedGasFeeCap, newGasFeeCap) + } + if !arbmath.BigEquals(expectedBlobFeeCap, newBlobFeeCap) { + t.Fatalf("feeAndTipCaps didn't return expected blob gas fee cap. Was: %d, expected: %d", expectedBlobFeeCap, newBlobFeeCap) + } + + // 2 gwei is the amount suggested by the L1 client, so that is the value + // returned because it doesn't exceed the configured bounds, there is no + // lastTx to scale against with rbf, and it is not bigger than the computed + // gasFeeCap. + expectedTipCap := big.NewInt(2 * params.GWei) + if !arbmath.BigEquals(expectedTipCap, newTipCap) { + t.Fatalf("feeAndTipCaps didn't return expected tip cap. Was: %d, expected: %d", expectedTipCap, newTipCap) + } + + lastBlobTx := &types.BlobTx{} + err = updateTxDataGasCaps(lastBlobTx, newGasFeeCap, newTipCap, newBlobFeeCap) + if err != nil { + t.Fatal(err) + } + lastTx = types.NewTx(lastBlobTx) + // Make creation time go backwards so elapsed time increases + retconnedCreationTime := dataCreatedAt.Add(-time.Minute) + // Base fee has decreased but blob fee has increased + blobGasUsed = 0xc0000 // 6 blobs of gas + excessBlobGas = 8295804 // this should set blob fee to 12 wei + latestHeader = types.Header{ + Number: big.NewInt(2), + BaseFee: big.NewInt(100_000_000), + BlobGasUsed: &blobGasUsed, + ExcessBlobGas: &excessBlobGas, + } + + newGasFeeCap, newTipCap, newBlobFeeCap, err = p.feeAndTipCaps(ctx, nonce, gasLimit, numBlobs, lastTx, retconnedCreationTime, dataPosterBacklog, &latestHeader) + + t.Log("newGasFeeCap", newGasFeeCap, "newTipCap", newTipCap, "newBlobFeeCap", newBlobFeeCap, "err", err) + if arbmath.BigEquals(expectedGasFeeCap, newGasFeeCap) { + t.Fatalf("feeAndTipCaps didn't return expected gas fee cap. Was: %d, expected NOT: %d", expectedGasFeeCap, newGasFeeCap) + } + if arbmath.BigEquals(expectedBlobFeeCap, newBlobFeeCap) { + t.Fatalf("feeAndTipCaps didn't return expected blob gas fee cap. Was: %d, expected NOT: %d", expectedBlobFeeCap, newBlobFeeCap) + } + if arbmath.BigEquals(expectedTipCap, newTipCap) { + t.Fatalf("feeAndTipCaps didn't return expected tip cap. Was: %d, expected NOT: %d", expectedTipCap, newTipCap) + } + +} diff --git a/broadcaster/broadcaster.go b/broadcaster/broadcaster.go index ed3088ca2e..242b8f9eeb 100644 --- a/broadcaster/broadcaster.go +++ b/broadcaster/broadcaster.go @@ -7,6 +7,7 @@ import ( "context" "errors" "net" + "runtime/debug" "github.com/gobwas/ws" @@ -60,7 +61,7 @@ func (b *Broadcaster) NewBroadcastFeedMessage(message arbostypes.MessageWithMeta func (b *Broadcaster) BroadcastSingle(msg arbostypes.MessageWithMetadata, seq arbutil.MessageIndex) (err error) { defer func() { if r := recover(); r != nil { - log.Error("recovered error in BroadcastSingle", "recover", r) + log.Error("recovered error in BroadcastSingle", "recover", r, "backtrace", string(debug.Stack())) err = errors.New("panic in BroadcastSingle") } }() @@ -84,7 +85,7 @@ func (b *Broadcaster) BroadcastSingleFeedMessage(bfm *m.BroadcastFeedMessage) { func (b *Broadcaster) BroadcastMessages(messages []arbostypes.MessageWithMetadata, seq arbutil.MessageIndex) (err error) { defer func() { if r := recover(); r != nil { - log.Error("recovered error in BroadcastMessages", "recover", r) + log.Error("recovered error in BroadcastMessages", "recover", r, "backtrace", string(debug.Stack())) err = errors.New("panic in BroadcastMessages") } }() diff --git a/util/headerreader/blob_client.go b/util/headerreader/blob_client.go index 7040bdee02..664dbb5e30 100644 --- a/util/headerreader/blob_client.go +++ b/util/headerreader/blob_client.go @@ -27,10 +27,11 @@ import ( ) type BlobClient struct { - ec arbutil.L1Interface - beaconUrl *url.URL - httpClient *http.Client - authorization string + ec arbutil.L1Interface + beaconUrl *url.URL + secondaryBeaconUrl *url.URL + httpClient *http.Client + authorization string // Filled in in Initialize() genesisTime uint64 @@ -41,19 +42,22 @@ type BlobClient struct { } type BlobClientConfig struct { - BeaconUrl string `koanf:"beacon-url"` - BlobDirectory string `koanf:"blob-directory"` - Authorization string `koanf:"authorization"` + BeaconUrl string `koanf:"beacon-url"` + SecondaryBeaconUrl string `koanf:"secondary-beacon-url"` + BlobDirectory string `koanf:"blob-directory"` + Authorization string `koanf:"authorization"` } var DefaultBlobClientConfig = BlobClientConfig{ - BeaconUrl: "", - BlobDirectory: "", - Authorization: "", + BeaconUrl: "", + SecondaryBeaconUrl: "", + BlobDirectory: "", + Authorization: "", } func BlobClientAddOptions(prefix string, f *pflag.FlagSet) { f.String(prefix+".beacon-url", DefaultBlobClientConfig.BeaconUrl, "Beacon Chain RPC URL to use for fetching blobs (normally on port 3500)") + f.String(prefix+".secondary-beacon-url", DefaultBlobClientConfig.SecondaryBeaconUrl, "Backup beacon Chain RPC URL to use for fetching blobs (normally on port 3500) when unable to fetch from primary") f.String(prefix+".blob-directory", DefaultBlobClientConfig.BlobDirectory, "Full path of the directory to save fetched blobs") f.String(prefix+".authorization", DefaultBlobClientConfig.Authorization, "Value to send with the HTTP Authorization: header for Beacon REST requests, must include both scheme and scheme parameters") } @@ -63,6 +67,12 @@ func NewBlobClient(config BlobClientConfig, ec arbutil.L1Interface) (*BlobClient if err != nil { return nil, fmt.Errorf("failed to parse beacon chain URL: %w", err) } + var secondaryBeaconUrl *url.URL + if config.SecondaryBeaconUrl != "" { + if secondaryBeaconUrl, err = url.Parse(config.BeaconUrl); err != nil { + return nil, fmt.Errorf("failed to parse secondary beacon chain URL: %w", err) + } + } if config.BlobDirectory != "" { if _, err = os.Stat(config.BlobDirectory); err != nil { if os.IsNotExist(err) { @@ -75,11 +85,12 @@ func NewBlobClient(config BlobClientConfig, ec arbutil.L1Interface) (*BlobClient } } return &BlobClient{ - ec: ec, - beaconUrl: beaconUrl, - authorization: config.Authorization, - httpClient: &http.Client{}, - blobDirectory: config.BlobDirectory, + ec: ec, + beaconUrl: beaconUrl, + secondaryBeaconUrl: secondaryBeaconUrl, + authorization: config.Authorization, + httpClient: &http.Client{}, + blobDirectory: config.BlobDirectory, }, nil } @@ -92,22 +103,43 @@ func beaconRequest[T interface{}](b *BlobClient, ctx context.Context, beaconPath var empty T - // not really a deep copy, but copies the Path part we care about - url := *b.beaconUrl - url.Path = path.Join(url.Path, beaconPath) - - req, err := http.NewRequestWithContext(ctx, "GET", url.String(), http.NoBody) - if err != nil { - return empty, err - } - - if b.authorization != "" { - req.Header.Set("Authorization", b.authorization) + fetchData := func(url url.URL) (*http.Response, error) { + url.Path = path.Join(url.Path, beaconPath) + req, err := http.NewRequestWithContext(ctx, "GET", url.String(), http.NoBody) + if err != nil { + return nil, err + } + if b.authorization != "" { + req.Header.Set("Authorization", b.authorization) + } + resp, err := b.httpClient.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + bodyStr := string(body) + log.Debug("beacon request returned response with non 200 OK status", "status", resp.Status, "body", bodyStr) + if len(bodyStr) > 100 { + return nil, fmt.Errorf("response returned with status %s, want 200 OK. body: %s ", resp.Status, bodyStr[len(bodyStr)-trailingCharsOfResponse:]) + } else { + return nil, fmt.Errorf("response returned with status %s, want 200 OK. body: %s", resp.Status, bodyStr) + } + } + return resp, nil } - resp, err := b.httpClient.Do(req) - if err != nil { - return empty, err + var resp *http.Response + var err error + if resp, err = fetchData(*b.beaconUrl); err != nil { + if b.secondaryBeaconUrl != nil { + log.Info("error fetching blob data from primary beacon URL, switching to secondary beacon URL", "err", err) + if resp, err = fetchData(*b.secondaryBeaconUrl); err != nil { + return empty, fmt.Errorf("error fetching blob data from secondary beacon URL: %w", err) + } + } else { + return empty, err + } } defer resp.Body.Close()