Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 29 additions & 13 deletions arbos/constraints/constraints.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,22 +50,43 @@ func (s ResourceSet) GetResources() []multigas.ResourceKind {

// ResourceConstraint defines the max gas target per second for the given period for a single resource.
type ResourceConstraint struct {
Resources ResourceSet
Period PeriodSecs
TargetPerSec uint64
Backlog uint64
resources ResourceSet
period PeriodSecs
targetPerSec uint64
backlog uint64
denominator uint64
}

func NewResourceConstraint(resources ResourceSet, periodSecs PeriodSecs, targetPerSec uint64) *ResourceConstraint {
constraint := &ResourceConstraint{
resources: resources,
period: periodSecs,
targetPerSec: targetPerSec,
backlog: 0,
}
constraint.updateDenominator()
return constraint
}

// AddToBacklog increases the constraint backlog given the multi-dimensional gas used.
func (c *ResourceConstraint) AddToBacklog(gasUsed multigas.MultiGas) {
for _, resource := range c.Resources.GetResources() {
c.Backlog = arbmath.SaturatingUAdd(c.Backlog, gasUsed.Get(resource))
for _, resource := range c.resources.GetResources() {
c.backlog = arbmath.SaturatingUAdd(c.backlog, gasUsed.Get(resource))
}
}

// RemoveFromBacklog decreases the backlog by its target given the amount of time passed.
func (c *ResourceConstraint) RemoveFromBacklog(timeElapsed uint64) {
c.Backlog = arbmath.SaturatingUSub(c.Backlog, timeElapsed*c.TargetPerSec)
c.backlog = arbmath.SaturatingUSub(c.backlog, timeElapsed*c.targetPerSec)
}

// updateDenominator recomputes the denominator based on the target and period.
func (c *ResourceConstraint) updateDenominator() {
// Compute inertia = 30 * sqrt(Δ_i)
inertia := PricingInertiaFactor * arbmath.ApproxSquareRoot(uint64(c.period))

// Compute denominator = inertia * T_i
c.denominator = arbmath.SaturatingUMul(inertia, c.targetPerSec)
}

// constraintKey identifies a resource constraint. There can be only one constraint given the
Expand Down Expand Up @@ -105,12 +126,7 @@ func (rc *ResourceConstraints) Set(
resources: resources,
period: periodSecs,
}
constraint := &ResourceConstraint{
Resources: resources,
Period: periodSecs,
TargetPerSec: targetPerSec,
Backlog: 0,
}
constraint := NewResourceConstraint(resources, periodSecs, targetPerSec)
rc.constraints[key] = constraint
}

Expand Down
44 changes: 22 additions & 22 deletions arbos/constraints/constraints_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ func TestResourceSetGetResources(t *testing.T) {
func TestAddToBacklog(t *testing.T) {
resources := EmptyResourceSet().WithResources(multigas.ResourceKindComputation, multigas.ResourceKindStorageAccess)
c := &ResourceConstraint{
Resources: resources,
Backlog: 0,
resources: resources,
backlog: 0,
}

gasUsed := multigas.MultiGasFromPairs(
Expand All @@ -56,32 +56,32 @@ func TestAddToBacklog(t *testing.T) {
)

c.AddToBacklog(gasUsed)
require.Equal(t, uint64(125), c.Backlog) // 50 + 75
require.Equal(t, uint64(125), c.backlog) // 50 + 75

// Test saturation
c.Backlog = math.MaxUint64 - 10
c.backlog = math.MaxUint64 - 10
c.AddToBacklog(gasUsed)
require.Equal(t, c.Backlog, uint64(math.MaxUint64))
require.Equal(t, c.backlog, uint64(math.MaxUint64))
}

func TestRemoveFromBacklog(t *testing.T) {
c := &ResourceConstraint{
Backlog: 1000,
TargetPerSec: 50,
backlog: 1000,
targetPerSec: 50,
}

// Remove a small amount
c.RemoveFromBacklog(10) // Remove 10 * 50 = 500
require.Equal(t, uint64(500), c.Backlog)
require.Equal(t, uint64(500), c.backlog)

// Remove the rest
c.RemoveFromBacklog(10) // Remove 10 * 50 = 500
require.Equal(t, uint64(0), c.Backlog)
require.Equal(t, uint64(0), c.backlog)

// Test saturation (underflow)
c.Backlog = 100
c.backlog = 100
c.RemoveFromBacklog(10) // Attempt to remove 500
require.Equal(t, uint64(0), c.Backlog)
require.Equal(t, uint64(0), c.backlog)
}

func TestNewResourceConstraints(t *testing.T) {
Expand All @@ -101,9 +101,9 @@ func TestSetResourceConstraints(t *testing.T) {

constraint := rc.Get(resources, periodSecs)
require.NotNil(t, constraint)
require.Equal(t, resources, constraint.Resources)
require.Equal(t, periodSecs, constraint.Period)
require.Equal(t, targetPerSec, constraint.TargetPerSec)
require.Equal(t, resources, constraint.resources)
require.Equal(t, periodSecs, constraint.period)
require.Equal(t, targetPerSec, constraint.targetPerSec)
}

func TestGetResourceConstraints(t *testing.T) {
Expand All @@ -117,10 +117,10 @@ func TestGetResourceConstraints(t *testing.T) {
// Test getting an existing constraint
constraint := rc.Get(resources, periodSecs)
require.NotNil(t, constraint)
require.Equal(t, resources, constraint.Resources)
require.Equal(t, periodSecs, constraint.Period)
require.Equal(t, targetPerSec, constraint.TargetPerSec)
require.Equal(t, uint64(0), constraint.Backlog)
require.Equal(t, resources, constraint.resources)
require.Equal(t, periodSecs, constraint.period)
require.Equal(t, targetPerSec, constraint.targetPerSec)
require.Equal(t, uint64(0), constraint.backlog)

// Test getting a non-existent constraint
nonExistentResources := EmptyResourceSet().WithResources(multigas.ResourceKindStorageAccess)
Expand Down Expand Up @@ -172,12 +172,12 @@ func TestAllResourceConstraints(t *testing.T) {
found1 := false
found2 := false
for _, c := range constraints {
if c.Resources == resources1 && c.Period == periodSecs1 {
require.Equal(t, targetPerSec1, c.TargetPerSec)
if c.resources == resources1 && c.period == periodSecs1 {
require.Equal(t, targetPerSec1, c.targetPerSec)
found1 = true
}
if c.Resources == resources2 && c.Period == periodSecs2 {
require.Equal(t, targetPerSec2, c.TargetPerSec)
if c.resources == resources2 && c.period == periodSecs2 {
require.Equal(t, targetPerSec2, c.targetPerSec)
found2 = true
}
}
Expand Down
48 changes: 48 additions & 0 deletions arbos/constraints/model.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Copyright 2025, Offchain Labs, Inc.
// For license information, see https://github.com/OffchainLabs/nitro/blob/master/LICENSE.md

// The constraints package tracks the multi-dimensional gas usage to apply constraint-based pricing.
package constraints

import (
"math/big"

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

const (
PricingInertiaFactor = 30
)

type PricingState struct {
constraints ResourceConstraints
}

// UpdatePricingModel adjusts the basefee according to simplified constraint-based pricing.
// Formula: basefee = F_min * exp( max_i ( B_i / (30 * T_i * sqrt(Δ_i)) ) )
func (ps *PricingState) UpdatePricingModel(minBaseFee *big.Int, timePassed uint64) *big.Int {
var maxExponentBips arbmath.Bips

for c := range ps.constraints.All() {
// Decay per-constraint backlog by T_i * timePassed
c.RemoveFromBacklog(timePassed)

if c.backlog == 0 || c.targetPerSec == 0 || c.period == 0 {
continue
}

// Normalized backlog = B_i / denominator
expBips := arbmath.NaturalToBips(arbmath.SaturatingCast[int64](c.backlog)) / arbmath.SaturatingCast[arbmath.Bips](c.denominator)

// Pick the maximum exponent across all constraints
if expBips > maxExponentBips {
maxExponentBips = expBips
}
}

// Apply the maximum exponent
if maxExponentBips == 0 {
return new(big.Int).Set(minBaseFee)
}
return arbmath.BigMulByBips(minBaseFee, arbmath.ApproxExpBasisPoints(maxExponentBips, 4))
}
125 changes: 125 additions & 0 deletions arbos/constraints/model_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// Copyright 2025, Offchain Labs, Inc.
// For license information, see https://github.com/OffchainLabs/nitro/blob/master/LICENSE.md

package constraints

import (
"math/big"
"testing"

"github.com/stretchr/testify/require"

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

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

func TestConstraintsModelTwoResources(t *testing.T) {
// Params: target = 5M/sec, Δ = 10s
var target uint64 = 5_000_000
const periodSecs = PeriodSecs(10)
const iterations = 30

// Setup new constraint-based pricing model with 2 resources
constraints := NewResourceConstraints()
resources := EmptyResourceSet().
WithResources(
multigas.ResourceKindComputation,
multigas.ResourceKindStorageAccess,
)
constraints.Set(resources, periodSecs, target)

model := PricingState{constraints: *constraints}

// Base fee floor
baseFee := big.NewInt(100_000_000)

// Phase 1: exceed target to force fee increase
for i := 0; i < iterations; i++ {
mg := multigas.MultiGasFromPairs(
multigas.Pair{Kind: multigas.ResourceKindComputation, Amount: 5},
multigas.Pair{Kind: multigas.ResourceKindStorageAccess, Amount: 2},
)
model.constraints.Get(resources, periodSecs).AddToBacklog(mg)

newFee := model.UpdatePricingModel(baseFee, 1)

// Fee should never fall below baseFee during surge
require.GreaterOrEqualf(t, newFee.Cmp(baseFee), 0,
"fee dropped below base fee at iter %d", i)
}

// Phase 2: no usage, backlog drains and fee should decay back
for i := 0; i < iterations*2; i++ {
newFee := model.UpdatePricingModel(baseFee, 1)

// Fee must eventually reach the floor
if i == iterations*2-1 {
require.Equal(t, 0, newFee.Cmp(baseFee),
"fee should decay back to base fee")
}
}
}

func TestConstraintsModelVersusLegacy(t *testing.T) {
// Test parameters
var gasUsedPerSecond int64 = 8_000_000 // >7M target to accumulate backlog
var iterations int = 50
var periodSecs = PeriodSecs(12)

// Initialize L2PricingState with legacy pricing model
burner := burn.NewSystemBurner(nil, false)
storage := storage.NewMemoryBacked(burner)
require.NoError(t, l2pricing.InitializeL2PricingState(storage))
l2PricingState := l2pricing.OpenL2PricingState(storage)

// Match new model
_ = l2PricingState.SetBacklogTolerance(0) // no tolerance
require.NoError(t, l2PricingState.SetSpeedLimitPerSecond(l2pricing.InitialSpeedLimitPerSecondV6))

// Setup constraint-based pricing model with a single gas constraint
constraints := NewResourceConstraints()
resources := EmptyResourceSet().
WithResources(
multigas.ResourceKindComputation,
multigas.ResourceKindStorageAccess,
multigas.ResourceKindStorageGrowth,
multigas.ResourceKindHistoryGrowth,
multigas.ResourceKindWasmComputation,
)
constraints.Set(resources, periodSecs, l2pricing.InitialSpeedLimitPerSecondV6)
model := PricingState{
constraints: *constraints,
}

minBaseFee, _ := l2PricingState.MinBaseFeeWei()

for i := 1; i < iterations+1; i++ {
// L2PricingState model update
baseFeeLegacy, _ := l2PricingState.BaseFeeWei()
burner.Restrict(l2PricingState.AddToGasPool(-gasUsedPerSecond)) // negative = gas consumed
l2PricingState.UpdatePricingModel(baseFeeLegacy, 1, false)
legacyFee, _ := l2PricingState.BaseFeeWei()

// Constraint-based model update
// #nosec G115 -- gasUsedPerSecond is a fixed positive constant for testing
mg := multigas.ComputationGas(uint64(gasUsedPerSecond))
model.constraints.Get(resources, periodSecs).AddToBacklog(mg)
newFee := model.UpdatePricingModel(minBaseFee, 1)

diff := new(big.Float).Quo(
new(big.Float).SetInt(legacyFee),
new(big.Float).SetInt(newFee),
)
val, _ := diff.Float64()

require.InEpsilonf(t, 1.0, val, 0.01, // within 1% tolerance
"fees differ too much at iteration %d: legacy=%s new=%s",
i, legacyFee.String(), newFee.String())

// Uncomment for debug output
// fmt.Printf("%-4d %-15s %-15s %-10.4f\n", i, legacyFee.String(), newFee.String(), val)
}
}
Loading