Skip to content
Draft
44 changes: 44 additions & 0 deletions arbos/l2pricing/base_fees.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright 2025, Offchain Labs, Inc.
// For license information, see https://github.com/OffchainLabs/nitro/blob/master/LICENSE.md

package l2pricing

import (
"math/big"

"github.com/ethereum/go-ethereum/arbitrum/multigas"

"github.com/offchainlabs/nitro/arbos/storage"
)

const (
baseFeesOffset uint64 = iota
)

// MultiGasBaseFees defines the base fees tracked for multiple gas resource kinds.
type MultiGasBaseFees struct {
baseFees [multigas.NumResourceKind]storage.StorageBackedBigInt
}

// OpenMultiGasBaseFees opens or initializes base fees in the given storage subspace.
func OpenMultiGasBaseFees(sto *storage.Storage) *MultiGasBaseFees {
r := &MultiGasBaseFees{
baseFees: [multigas.NumResourceKind]storage.StorageBackedBigInt{},
}
for i := range int(multigas.NumResourceKind) {
// #nosec G115 safe: NumResourceKind < 2^32
offset := baseFeesOffset + uint64(i)
r.baseFees[i] = sto.OpenStorageBackedBigInt(offset)
}
return r
}

// Get retrieves the base fee for the given resource kind.
func (bf *MultiGasBaseFees) Get(kind multigas.ResourceKind) (*big.Int, error) {
return bf.baseFees[kind].Get()
}

// Set sets the base fee for the given resource kind.
func (bf *MultiGasBaseFees) Set(kind multigas.ResourceKind, v *big.Int) error {
return bf.baseFees[kind].SetChecked(v)
}
17 changes: 10 additions & 7 deletions arbos/l2pricing/l2pricing.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ type L2PricingState struct {
backlogTolerance storage.StorageBackedUint64
perTxGasLimit storage.StorageBackedUint64
gasConstraints *storage.SubStorageVector
multigasConstraints *storage.SubStorageVector
multiGasConstraints *storage.SubStorageVector
multiGasBaseFees *MultiGasBaseFees

ArbosVersion uint64
}
Expand All @@ -42,7 +43,8 @@ const (
)

var gasConstraintsKey []byte = []byte{0}
var multigasConstraintsKey []byte = []byte{1}
var multiGasConstraintsKey []byte = []byte{1}
var multiGasBaseFeesKey []byte = []byte{2}

const GethBlockGasLimit = 1 << 50

Expand Down Expand Up @@ -75,7 +77,8 @@ func OpenL2PricingState(sto *storage.Storage, arbosVersion uint64) *L2PricingSta
backlogTolerance: sto.OpenStorageBackedUint64(backlogToleranceOffset),
perTxGasLimit: sto.OpenStorageBackedUint64(perTxGasLimitOffset),
gasConstraints: storage.OpenSubStorageVector(sto.OpenSubStorage(gasConstraintsKey)),
multigasConstraints: storage.OpenSubStorageVector(sto.OpenSubStorage(multigasConstraintsKey)),
multiGasConstraints: storage.OpenSubStorageVector(sto.OpenSubStorage(multiGasConstraintsKey)),
multiGasBaseFees: OpenMultiGasBaseFees(sto.OpenSubStorage(multiGasBaseFeesKey)),
ArbosVersion: arbosVersion,
}
}
Expand Down Expand Up @@ -268,11 +271,11 @@ func (ps *L2PricingState) ClearGasConstraints() error {
}

func (ps *L2PricingState) MultiGasConstraintsLength() (uint64, error) {
return ps.multigasConstraints.Length()
return ps.multiGasConstraints.Length()
}

func (ps *L2PricingState) OpenMultiGasConstraintAt(i uint64) *MultiGasConstraint {
return OpenMultiGasConstraint(ps.multigasConstraints.At(i))
return OpenMultiGasConstraint(ps.multiGasConstraints.At(i))
}

func (ps *L2PricingState) AddMultiGasConstraint(
Expand All @@ -281,7 +284,7 @@ func (ps *L2PricingState) AddMultiGasConstraint(
backlog uint64,
weights map[uint8]uint64,
) error {
subStorage, err := ps.multigasConstraints.Push()
subStorage, err := ps.multiGasConstraints.Push()
if err != nil {
return fmt.Errorf("failed to push multi-gas constraint: %w", err)
}
Expand All @@ -308,7 +311,7 @@ func (ps *L2PricingState) ClearMultiGasConstraints() error {
return err
}
for range length {
subStorage, err := ps.multigasConstraints.Pop()
subStorage, err := ps.multiGasConstraints.Pop()
if err != nil {
return err
}
Expand Down
57 changes: 46 additions & 11 deletions arbos/l2pricing/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ func (ps *L2PricingState) updateSingleGasConstraintsBacklogs(op BacklogOperation
}

func (ps *L2PricingState) updateMultiGasConstraintsBacklogs(op BacklogOperation, _usedGas uint64, usedMultiGas multigas.MultiGas) error {
constraintsLength, err := ps.multigasConstraints.Length()
constraintsLength, err := ps.multiGasConstraints.Length()
if err != nil {
return err
}
Expand Down Expand Up @@ -161,7 +161,7 @@ func (ps *L2PricingState) BacklogUpdateCost() uint64 {
result += storage.StorageReadCost

// updateMultiGasConstraintsBacklogs costs
constraintsLength, _ := ps.multigasConstraints.Length()
constraintsLength, _ := ps.multiGasConstraints.Length()
if constraintsLength > 0 {
result += storage.StorageReadCost // read length to traverse

Expand Down Expand Up @@ -272,17 +272,20 @@ func (ps *L2PricingState) updatePricingModelMultiConstraints(timePassed uint64)
// Calculate exponents per resource kind for all constraints
exponentPerKind, _ := ps.CalcMultiGasConstraintsExponents()

// Choose the most congested resource
maxExponent := arbmath.Bips(0)
for _, exp := range exponentPerKind {
if exp > maxExponent {
maxExponent = exp
// Compute base fee per resource kind, store and choose the most congested resource
maxBaseFee, _ := ps.MinBaseFeeWei()
for kind, exp := range exponentPerKind {
baseFee, _ := ps.calcBaseFeeFromExponent(exp)

// #nosec G115 safe: kind < multigas.NumResourceKind
_ = ps.multiGasBaseFees.Set(multigas.ResourceKind(kind), baseFee)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ResourceKindL1Calldata is a special case that won't have L2 constraints. When computing the L1-calldata gas, the L1-pricing model uses the single-dimensional base fee, which is the max base fee. (Roughly, gasUsed[ResourceKindL1Calldata] = posterFee / baseFee). So, this dimension should be maxBaseFee as well, otherwise we will be refunding almost all L1-calldata gas.

Copy link
Contributor Author

@MishkaRogachev MishkaRogachev Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At present, GrowBacklog does not receive gas in this dimension, and there should be no constraints on L1-calldata (I would suggest prohibiting this in ArbOwner). So I would suggest to work-around it in MultiDimensionalPriceForRefund together with zero-fee check

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At present, GrowBacklog does not receive gas in this dimension, and there should be no constraints on L1-calldata

That is correct.

I would suggest prohibiting this in ArbOwner

I agree.

As a result, the base fee should always be minBaseFee and L1-calldata gas will never be refunded.

We can solve this problem in two ways.

  • The first one that I proposed was setting the base fee for this dimension as the maxBaseFee. Then, MultiDimensionalPriceForRefund doesn't need to handle any special case and naturally won't give a refund to L1-calldata.
  • The second one is letting the base fee for this dimension be the min base fee since it won't have any constraint. Then, in MultiDimensionalPriceForRefund, we should handle this dimension separately to ensure we won't give a refund.

I'm fine with either approach.


if baseFee.Cmp(maxBaseFee) > 0 {
maxBaseFee = baseFee
}
}

// Compute base fee
baseFee, _ := ps.calcBaseFeeFromExponent(maxExponent)
_ = ps.SetBaseFeeWei(baseFee)
}
_ = ps.SetBaseFeeWei(maxBaseFee)
}

// CalcMultiGasConstraintsExponents calculates the exponents for each resource kind
Expand Down Expand Up @@ -347,3 +350,35 @@ func (ps *L2PricingState) calcBaseFeeFromExponent(exponent arbmath.Bips) (*big.I
return minBaseFee, nil
}
}

func (ps *L2PricingState) MultiDimensionalPriceForRefund(gasUsed multigas.MultiGas) (*big.Int, error) {
// Base fee is max of per-resource-kind base fees
baseFeeWei, err := ps.BaseFeeWei()
if err != nil {
return nil, err
}

total := new(big.Int)
for kind := range multigas.ResourceKind(multigas.NumResourceKind) {
baseFee, err := ps.multiGasBaseFees.Get(kind)
if err != nil {
return nil, err
}
// Force L1 calldata (and the unlikely zero-basefee case) to use the max base fee.
if kind == multigas.ResourceKindL1Calldata || baseFee.Cmp(big.NewInt(0)) == 0 {
baseFee = baseFeeWei
}

amount := gasUsed.Get(kind)
if amount == 0 {
continue
}

part := new(big.Int).Mul(
new(big.Int).SetUint64(amount),
baseFee,
)
total.Add(total, part)
}
return total, nil
}
119 changes: 119 additions & 0 deletions arbos/l2pricing/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import (
"slices"
"testing"

"github.com/ethereum/go-ethereum/arbitrum/multigas"
"github.com/ethereum/go-ethereum/params"

"github.com/offchainlabs/nitro/util/arbmath"
)

func toGwei(wei *big.Int) string {
Expand Down Expand Up @@ -124,3 +127,119 @@ func TestCompareSingleGasConstraintsPricingModelWithMultiGasConstraints(t *testi
}
}
}

func TestCalcMultiGasConstraintsExponents(t *testing.T) {
pricing := PricingForTest(t)
pricing.ArbosVersion = ArbosMultiGasConstraintsVersion

Require(t, pricing.AddMultiGasConstraint(
100000,
10,
20000,
map[uint8]uint64{
uint8(multigas.ResourceKindComputation): 1,
uint8(multigas.ResourceKindStorageAccess): 2,
},
))
Require(t, pricing.AddMultiGasConstraint(
50000,
5,
15000,
map[uint8]uint64{
uint8(multigas.ResourceKindStorageGrowth): 1,
},
))

exponents, err := pricing.CalcMultiGasConstraintsExponents()
Require(t, err)

// From constraint 1:
// exp_comp = floor(20000 * 1 * 10000 / (10 * 100000 * 3)) = 66
// exp_store = floor(20000 * 2 * 10000 / (10 * 100000 * 3)) = 133
if got, want := exponents[multigas.ResourceKindComputation], arbmath.Bips(66); got != want {
t.Errorf("unexpected computation exponent: got %v, want %v", got, want)
}
if got, want := exponents[multigas.ResourceKindStorageAccess], arbmath.Bips(133); got != want {
t.Errorf("unexpected storage-access exponent: got %v, want %v", got, want)
}

// From constraint 2:
// exp_storageGrowth = floor(15000 * 1 * 10000 / (5 * 50000 * 1)) = 600
if got, want := exponents[multigas.ResourceKindStorageGrowth], arbmath.Bips(600); got != want {
t.Errorf("unexpected storage-growth exponent: got %v, want %v", got, want)
}

// All other kinds should be zero
if got := exponents[multigas.ResourceKindHistoryGrowth]; got != 0 {
t.Errorf("expected zero history-growth exponent, got %v", got)
}
if got := exponents[multigas.ResourceKindL1Calldata]; got != 0 {
t.Errorf("expected zero L1 calldata exponent, got %v", got)
}
if got := exponents[multigas.ResourceKindL2Calldata]; got != 0 {
t.Errorf("expected zero L2 calldata exponent, got %v", got)
}
if got := exponents[multigas.ResourceKindWasmComputation]; got != 0 {
t.Errorf("expected zero wasm computation exponent, got %v", got)
}
}

func TestMultiDimensionalPriceForRefund(t *testing.T) {
pricing := PricingForTest(t)

minPrice, err := pricing.MinBaseFeeWei()
Require(t, err)

multiGas := multigas.MultiGasFromPairs(
multigas.Pair{Kind: multigas.ResourceKindComputation, Amount: 50000},
multigas.Pair{Kind: multigas.ResourceKindStorageAccess, Amount: 15000},
)
// #nosec G115
singleGas := big.NewInt(int64(multiGas.SingleGas()))
// Initial price should match minBaseFeeWei * singleGas
expectedPrice := minPrice.Mul(minPrice, singleGas)
Require(t, err)

pricing.ArbosVersion = ArbosMultiGasConstraintsVersion

// Initial price check
price, err := pricing.MultiDimensionalPriceForRefund(multiGas)
Require(t, err)
if price.Cmp(expectedPrice) != 0 {
t.Errorf("Unexpected initial price: got %v, want %v", price, expectedPrice)
}

// updatePricingModelMultiConstraints() should set multi gas base fees
Require(t, pricing.AddMultiGasConstraint(
100000,
10,
20000,
map[uint8]uint64{
uint8(multigas.ResourceKindComputation): 1,
uint8(multigas.ResourceKindStorageAccess): 2,
},
))
Require(t, pricing.AddMultiGasConstraint(
50000,
5,
15000,
map[uint8]uint64{
uint8(multigas.ResourceKindComputation): 2,
uint8(multigas.ResourceKindStorageAccess): 1,
},
))
usedMultiGas := multigas.MultiGasFromPairs(
multigas.Pair{Kind: multigas.ResourceKindComputation, Amount: 500000},
multigas.Pair{Kind: multigas.ResourceKindStorageAccess, Amount: 1500000},
)
err = pricing.GrowBacklog(usedMultiGas.SingleGas(), usedMultiGas)
Require(t, err)

pricing.updatePricingModelMultiConstraints(10)

price, err = pricing.MultiDimensionalPriceForRefund(multiGas)
Require(t, err)
if price.Cmp(expectedPrice) <= 0 {
t.Errorf("Price did not increase after backlog growth: got %v, want > %v", price, expectedPrice)
}
}
Loading
Loading