sae: Support oscillating MinPrice configs#5279
Conversation
…ego into sae-price-manipulation
…ego into sae-price-manipulation
|
Aside: setting the lower bound on |
ARR4N
left a comment
There was a problem hiding this comment.
Still need to review acp176_test.go but sharing everything else early.
| // 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()) | ||
| } |
There was a problem hiding this comment.
Reusing v everywhere is unclear. I think the following is easier to reason about:
| // 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 | |
| } |
There was a problem hiding this comment.
(no action required) Out of interest, why don't you like the short-circuit equality check?
There was a problem hiding this comment.
Definitely opinionated haha, but I always want to feel like code as a purpose. For me, this if statement would convey either:
- This is an edge case we are guarding against (which it isn't)
- This is a performance improvement (which should come with a benchmark)
- 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.
ARR4N
left a comment
There was a problem hiding this comment.
Review of TestAfterBlock().
| newKonT: 0, // invalid | ||
| T: 1, M: 1, KonT: 1, | ||
| newM: 1, | ||
| name: "high_price_scaling_causes_price_increase", |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| 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", |
There was a problem hiding this comment.
Please can you explain these two intermediate scaling tests.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Gotcha; no loss of resolution due to clipping.
| name: "invalid_target_override", | ||
| init: state{ | ||
| target: 0, |
There was a problem hiding this comment.
This seems like a strange test. When would we ever be in such an invalid state and need to override it?
There was a problem hiding this comment.
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
ARR4N
left a comment
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
Confirming my understanding. gotP < p i.f.f. p is unrepresentable, so we assert that the smallest possible increase in excess skips p?
There was a problem hiding this comment.
Correct.
The x != math.MaxUint64 is also important, as it's possible that gotP << p, which in that case x == math.MaxUint64.
| func TestOscillatingMinPrice(t *testing.T) { | ||
| const ( | ||
| target gas.Gas = 1_000_000 | ||
| gasPerBlock gas.Gas = target // must be sufficiently large |
There was a problem hiding this comment.
(nit) The reuse of target suggests that it's a deliberate choice rather than something arbitrary but large.
| gasPerBlock gas.Gas = target // must be sufficiently large | |
| gasPerBlock gas.Gas = target * TargetToRate // all gas consumed |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
| }, | ||
| { | ||
| name: "intermediate_scaling_overflow_rounds_up", | ||
| name: "scaling_old_k_overflow_rounds_up", |
There was a problem hiding this comment.
What is rounding up? Isn't the previous case (with price of 2) rounding up?
There was a problem hiding this comment.
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)
| }, | ||
| } | ||
| opts := cmp.Options{ | ||
| cmpopts.IgnoreTypes(canotoData_GasPriceConfig{}), |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
It isn't complaining elsewhere because Config is ignored entirely elsewhere. I'll add it to the require
| // 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()) | ||
| } |
There was a problem hiding this comment.
(no action required) Out of interest, why don't you like the short-circuit equality check?
| 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", |
There was a problem hiding this comment.
Gotcha; no loss of resolution due to clipping.
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
GasPriceConfigto be semi-untrusted. Currently, the implementation ofAfterBlockassumes that theGasPriceConfigis 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.AfterBlockagainst semi-maliciousGasPriceConfigchanges.How this works
The ACP-176 mechanism calculates price as:
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:
where
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:
MinPricefromgas.CalculatePriceand handles everything in the exponent.StaticPricingso thatexcessis always pinned to the minimum (rather than ignoringexcessduringPrice.) 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.Tand theScalingconstant, rather than binary searching when the scaling constant is changed.How this was tested
FuzzPriceInvarianceAfterBlockwith a large test table asserting excess and price inTestAfterBlockpriceExcess(which calculatesln(p) * k).Need to be documented in RELEASES.md?
No.