Skip to content

sae: Support oscillating MinPrice configs#5279

Merged
StephenButtolph merged 60 commits intomasterfrom
sae-price-manipulation
Apr 28, 2026
Merged

sae: Support oscillating MinPrice configs#5279
StephenButtolph merged 60 commits intomasterfrom
sae-price-manipulation

Conversation

@StephenButtolph
Copy link
Copy Markdown
Contributor

@StephenButtolph StephenButtolph commented Apr 14, 2026

Why this should be merged

Resolves #5242

There has been increased desired to make the minimum price configurable on the C-chain: avalanche-foundation/ACPs#283

However, this requires the GasPriceConfig to be semi-untrusted. Currently, the implementation of AfterBlock assumes that the GasPriceConfig is set by some "trusted" entity.

While this makes sense for a subnet-evm instance, it doesn't work for the C-chain, where a malicious block producer may try to keep the gas price artificially low.

This PR fortifies the gastime.Time.AfterBlock against semi-malicious GasPriceConfig changes.

How this works

The ACP-176 mechanism calculates price as:

$$ P := M \cdot e^{\left(\frac{x}{K}\right)} $$

Currently, we support changing $M$ by tracking the previous price, and then binary searching for the closest excess such that the new price will be as close to the old price as possible.

This works, but only if $M$ is only rarely changed.

We can instead modify the price calculation to be:

$$ P := e^{\left(\frac{x}{K}\right)} $$

where

$$ x \geq \ln(M) \cdot K $$

This is equivalent, other than integer approximation differences, but allows us to never reduce $x$ during a min price change, and only increase $x$ when the min price is increased past the prior price.


Specifically, this change:

  1. Removes MinPrice from gas.CalculatePrice and handles everything in the exponent.
  2. Changes StaticPricing so that excess is always pinned to the minimum (rather than ignoring excess during Price.) TBH I feel like this could be considered a bug-fix in its own right, since excess is exposed publicly and not just an internal gasprice implementation detail.
  3. Changes excess scaling to linearly scale based on the product of T and the Scaling constant, rather than binary searching when the scaling constant is changed.

How this was tested

  • Added a regression test for min price oscillations.
  • Replaced FuzzPriceInvarianceAfterBlock with a large test table asserting excess and price in TestAfterBlock
  • Added a new fuzz test for priceExcess (which calculates ln(p) * k).

Need to be documented in RELEASES.md?

No.

@StephenButtolph StephenButtolph self-assigned this Apr 14, 2026
Comment thread vms/saevm/gastime/acp176_test.go
Comment thread vms/saevm/gastime/acp176_test.go
Comment thread vms/saevm/gastime/acp176_test.go
Comment thread vms/saevm/gastime/acp176_test.go
Comment thread vms/saevm/gastime/acp176_test.go
Comment thread vms/saevm/gastime/acp176_test.go Outdated
Comment thread vms/saevm/gastime/acp176_test.go
Comment thread vms/saevm/gastime/acp176_test.go Outdated
Comment thread vms/saevm/gastime/acp176_test.go
Comment thread vms/saevm/gastime/acp176_test.go Outdated
Comment thread vms/saevm/gastime/acp176_test.go
Comment thread vms/saevm/gastime/acp176_test.go
@StephenButtolph StephenButtolph moved this to In Progress 🏗️ in avalanchego Apr 16, 2026
@StephenButtolph StephenButtolph changed the title sae: support oscillating MinPrice configs sae: Support oscillating MinPrice configs Apr 25, 2026
@ARR4N
Copy link
Copy Markdown
Contributor

ARR4N commented Apr 27, 2026

Aside: setting the lower bound on $x$ while removing $M$ entirely has the added benefit of a change in the minimum price not affecting the rate of change of price by scaling the entire $e^{\frac{x}{K}}$ component.

Copy link
Copy Markdown
Contributor

@ARR4N ARR4N left a comment

Choose a reason for hiding this comment

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

Still need to review acp176_test.go but sharing everything else early.

Comment thread vms/saevm/gastime/acp176.go
Comment thread vms/saevm/gastime/acp176.go Outdated
Comment thread vms/saevm/gastime/acp176.go Outdated
Comment on lines +81 to +107
// scaleExcess returns x * newT * newScale / (oldT * oldScale) rounded up and
// capped to [math.MaxUint64].
func scaleExcess(x, newT, newScale, oldT, oldScale gas.Gas) gas.Gas {
var (
newK uint256.Int
v uint256.Int
)
newK.SetUint64(uint64(newT))
v.SetUint64(uint64(newScale))
newK.Mul(&newK, &v)

var oldK uint256.Int
oldK.SetUint64(uint64(oldT))
v.SetUint64(uint64(oldScale))
oldK.Mul(&oldK, &v)

v.SetUint64(uint64(x))
v.Mul(&v, &newK)
v.Add(&v, &oldK) // round up by adding oldK - 1
v.SubUint64(&v, 1)
v.Div(&v, &oldK)

if !v.IsUint64() {
return math.MaxUint64
}
return gas.Gas(v.Uint64())
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reusing v everywhere is unclear. I think the following is easier to reason about:

Suggested change
// scaleExcess returns x * newT * newScale / (oldT * oldScale) rounded up and
// capped to [math.MaxUint64].
func scaleExcess(x, newT, newScale, oldT, oldScale gas.Gas) gas.Gas {
var (
newK uint256.Int
v uint256.Int
)
newK.SetUint64(uint64(newT))
v.SetUint64(uint64(newScale))
newK.Mul(&newK, &v)
var oldK uint256.Int
oldK.SetUint64(uint64(oldT))
v.SetUint64(uint64(oldScale))
oldK.Mul(&oldK, &v)
v.SetUint64(uint64(x))
v.Mul(&v, &newK)
v.Add(&v, &oldK) // round up by adding oldK - 1
v.SubUint64(&v, 1)
v.Div(&v, &oldK)
if !v.IsUint64() {
return math.MaxUint64
}
return gas.Gas(v.Uint64())
}
// scaleExcess returns oldX * newT * newScale / (oldT * oldScale) rounded up and
// capped to [math.MaxUint64].
func scaleExcess(oldX, newT, newScale, oldT, oldScale gas.Gas) gas.Gas {
newK := mulAsUint256(newT, newScale)
oldK := mulAsUint256(oldT, oldScale)
if newK.Eq(&oldK) {
return oldX
}
var x uint256.Int
x.SetUint64(uint64(oldX))
x.Mul(&x, &newK)
x.Add(&x, &oldK) // round up by adding oldK - 1
x.SubUint64(&x, 1)
x.Div(&x, &oldK)
if !x.IsUint64() {
return math.MaxUint64
}
return gas.Gas(x.Uint64())
}
func mulAsUint256[T ~uint64](a, b T) uint256.Int {
var x, y uint256.Int
x.SetUint64(uint64(a))
y.SetUint64(uint64(b))
x.Mul(&x, &y)
return x
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

(no action required) Out of interest, why don't you like the short-circuit equality check?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Definitely opinionated haha, but I always want to feel like code as a purpose. For me, this if statement would convey either:

  1. This is an edge case we are guarding against (which it isn't)
  2. This is a performance improvement (which should come with a benchmark)
  3. This is trying to convey the behavior of the function (which would better live as a comment than changing the code imo).

If the intent of the suggestion was (2), which I think is probably valid (albeit likely a micro-optimization), then I think we'd want to add a benchmark so that we could understand how much it actually matters.

Comment thread vms/saevm/gastime/acp176.go
Comment thread vms/saevm/gastime/acp176.go Outdated
Comment thread vms/saevm/gastime/acp176.go
Comment thread vms/saevm/gastime/gastime_test.go Outdated
Comment thread vms/saevm/gastime/gastime_test.go Outdated
Comment thread vms/saevm/gastime/gastime_test.go
Copy link
Copy Markdown
Contributor

@ARR4N ARR4N left a comment

Choose a reason for hiding this comment

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

Review of TestAfterBlock().

Comment thread vms/saevm/gastime/acp176_test.go Outdated
Comment thread vms/saevm/gastime/acp176_test.go Outdated
Comment thread vms/saevm/gastime/acp176_test.go Outdated
Comment thread vms/saevm/gastime/acp176_test.go Outdated
Comment thread vms/saevm/gastime/acp176_test.go Outdated
Comment thread vms/saevm/gastime/acp176_test.go Outdated
Comment thread vms/saevm/gastime/acp176_test.go Outdated
newKonT: 0, // invalid
T: 1, M: 1, KonT: 1,
newM: 1,
name: "high_price_scaling_causes_price_increase",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is the fact that it's an increase a property that we want/expect? If so, why? I was a little confused about the semantics of priceExcess() and I suspect this is related.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Is the fact that it's an increase a property that we want?

No.

Is the fact that it's an increase a property that we expect?

Yes.


This actually isn't relevant to priceExcess. The calculations for this test are as follows:

oldX := 1_802_924_127
newK := 50 * 1_000_000
oldK := 87 * 1_000_000
newX := oldX * newK / oldK
      = 1_802_924_127 * 50 / 87
     ~= 1_036_163_291.379...

If we round the excess down to 1_036_163_291, the price would decrease to 999_999_983.
We instead, round up to 1_036_163_292, where the price is 1_000_000_002.

It isn't possible for us to maintain price 999_999_990, so we need to decide what to do. We've always maintained rounding up to avoid any weird edge cases w.r.t. trying to game the price lower by block proposers... So this test is really just verifying that we are rounding up.

I changed the name of the test from high_price_scaling_causes_price_increase to high_price_scaling_rounding_causes_price_increase.

Comment thread vms/saevm/gastime/acp176_test.go Outdated
Comment on lines +574 to +595
name: "intermediate_scaling_overflow",
init: state{
target: 1_000_000,
excess: math.MaxUint64,
config: GasPriceConfig{
TargetToExcessScaling: math.MaxUint64,
MinPrice: 1,
},
price: 2,
},
new: state{
target: 1_000_000,
excess: 1,
config: GasPriceConfig{
TargetToExcessScaling: 1,
MinPrice: 1,
},
price: 1,
},
},
{
name: "intermediate_scaling_overflow_rounds_up",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please can you explain these two intermediate scaling tests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

When scaling x, we calculate oldK and newK.

newX := oldX * newK / oldK
newK := 1_000_000
oldK := 1_000_000 * MaxUint64

Previously, if oldK or newK exceeded MaxUint64, we capped them at MaxUint64.

So, previously, x would have been updated based on:

newX := oldX * newK / oldK
newK := 1_000_000
oldK := min(1_000_000 * MaxUint64, MaxUint64)
      = MaxUint64

So previously newX would have been 1_000_000, rather than the (correct) 1 which it is now.


I renamed the tests to scaling_old_k_overflow and scaling_old_k_overflow_rounds_up. I also added scaling_new_k_overflow.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Gotcha; no loss of resolution due to clipping.

Comment thread vms/saevm/gastime/acp176_test.go Outdated
Comment thread vms/saevm/gastime/acp176_test.go Outdated
Comment on lines +639 to +641
name: "invalid_target_override",
init: state{
target: 0,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This seems like a strange test. When would we ever be in such an invalid state and need to override it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is essentially testing the same case as tested in TestTargetClamping. We silently ignore a target=0, and clamp it to 0.

This shouldn't happen in practice, but we sanitize it here regardless

Copy link
Copy Markdown
Contributor

@ARR4N ARR4N left a comment

Choose a reason for hiding this comment

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

End of review. Sorry this took so long!

gotP := calculatePrice(x, k)
assert.LessOrEqual(t, gotP, p, "gotPrice <= wantPrice")

if gotP < p && x != math.MaxUint64 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Confirming my understanding. gotP < p i.f.f. p is unrepresentable, so we assert that the smallest possible increase in excess skips p?

Copy link
Copy Markdown
Contributor Author

@StephenButtolph StephenButtolph Apr 27, 2026

Choose a reason for hiding this comment

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

Correct.

The x != math.MaxUint64 is also important, as it's possible that gotP << p, which in that case x == math.MaxUint64.

Comment thread vms/saevm/gastime/acp176_test.go Outdated
func TestOscillatingMinPrice(t *testing.T) {
const (
target gas.Gas = 1_000_000
gasPerBlock gas.Gas = target // must be sufficiently large
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

(nit) The reuse of target suggests that it's a deliberate choice rather than something arbitrary but large.

Suggested change
gasPerBlock gas.Gas = target // must be sufficiently large
gasPerBlock gas.Gas = target * TargetToRate // all gas consumed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think the suggestion is a bit confusing (since we don't actually have a block GasLimit here... I removed the usage of target and just replaced it with a random number (10M) and left the prior comment.

newK := mulAsUint256(newT, newScale)
oldK := mulAsUint256(oldT, oldScale)

var x uint256.Int
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think a brief justification that x won't overflow is necessary. It would be pretty easy to add another multiplication here in a future refactor, and then the addition below could overflow.

As an alternative, is there any reason not to use big.Int?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I added a comment... I kinda waffled on the usefulness... But there is enough math going on that I can see why a specific S/O for the overflow case is reasonable.

As an alternative, is there any reason not to use big.Int?

big.Int is significantly less efficient.

Comment thread vms/saevm/gastime/acp176_test.go Outdated
Comment thread go.mod Outdated
},
{
name: "intermediate_scaling_overflow_rounds_up",
name: "scaling_old_k_overflow_rounds_up",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What is rounding up? Isn't the previous case (with price of 2) rounding up?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The previous case does not round up (the excess calculation):

MaxUint64 * 1_000_000 * 1 / (1_000_000 * math.MaxUint64)
(1_000_000 * MaxUint64) / (1_000_000 * math.MaxUint64)
1 (no rounding needed)

This case does round up:

1 * 1_000_000 * 1 / (1_000_000 * math.MaxUint64)
(1_000_000 * 1) / (1_000_000 * math.MaxUint64)
1 / MaxUint64
1 (after rounding)

Comment thread vms/saevm/gastime/acp176_test.go Outdated
},
}
opts := cmp.Options{
cmpopts.IgnoreTypes(canotoData_GasPriceConfig{}),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This should probably be appended to the incoming options in requireState() as it will be common to all. I'm not sure why it's not complaining elsewhere though.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It isn't complaining elsewhere because Config is ignored entirely elsewhere. I'll add it to the require

Comment thread vms/saevm/gastime/acp176.go Outdated
Comment on lines +81 to +107
// scaleExcess returns x * newT * newScale / (oldT * oldScale) rounded up and
// capped to [math.MaxUint64].
func scaleExcess(x, newT, newScale, oldT, oldScale gas.Gas) gas.Gas {
var (
newK uint256.Int
v uint256.Int
)
newK.SetUint64(uint64(newT))
v.SetUint64(uint64(newScale))
newK.Mul(&newK, &v)

var oldK uint256.Int
oldK.SetUint64(uint64(oldT))
v.SetUint64(uint64(oldScale))
oldK.Mul(&oldK, &v)

v.SetUint64(uint64(x))
v.Mul(&v, &newK)
v.Add(&v, &oldK) // round up by adding oldK - 1
v.SubUint64(&v, 1)
v.Div(&v, &oldK)

if !v.IsUint64() {
return math.MaxUint64
}
return gas.Gas(v.Uint64())
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

(no action required) Out of interest, why don't you like the short-circuit equality check?

Comment thread vms/saevm/gastime/acp176_test.go Outdated
Comment on lines +574 to +595
name: "intermediate_scaling_overflow",
init: state{
target: 1_000_000,
excess: math.MaxUint64,
config: GasPriceConfig{
TargetToExcessScaling: math.MaxUint64,
MinPrice: 1,
},
price: 2,
},
new: state{
target: 1_000_000,
excess: 1,
config: GasPriceConfig{
TargetToExcessScaling: 1,
MinPrice: 1,
},
price: 1,
},
},
{
name: "intermediate_scaling_overflow_rounds_up",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Gotcha; no loss of resolution due to clipping.

@StephenButtolph StephenButtolph merged commit f5ff45c into master Apr 28, 2026
65 of 66 checks passed
@StephenButtolph StephenButtolph deleted the sae-price-manipulation branch April 28, 2026 16:19
@github-project-automation github-project-automation Bot moved this from In Progress 🏗️ to Done 🎉 in avalanchego Apr 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done 🎉

Development

Successfully merging this pull request may close these issues.

Consider using MinExcess over MinPrice

5 participants