Skip to content

Conversation

StephenButtolph
Copy link
Contributor

Why this should be merged

The fees logic is factored out of #3287.

How this works

ACP-77 charges validators fees over time. While it may be possible to solve for these fees directly using integrals, the approximation function makes performing this optimization difficult.

Therefore, this PR does not attempt to calculate the fees in constant time (w.r.t. the duration). Rather, it permits falling back to a linear iteration (based on the duration of the fee charge). This is fine for our performance needs, as the duration is limited to the number of seconds between blocks on the chain and calculating weeks of fees takes milliseconds.

There are some special cases that are further optimized.

There is also a derivation of the excessConversionConstant based on the capacity, target, and minimumTimeToDouble.

How this was tested

  • New unit tests
  • New benchmarks
  • Both CalculateCost and CalculateDuration have "naive" implementations that were used to fuzz the optimized implementations.

@StephenButtolph StephenButtolph added this to the v1.11.12 milestone Sep 6, 2024
@StephenButtolph StephenButtolph self-assigned this Sep 6, 2024
@StephenButtolph
Copy link
Contributor Author

TODO: Add dhruba as co-author.

marun
marun previously approved these changes Sep 9, 2024
@marun marun dismissed their stale review September 9, 2024 23:27

I'm not qualified to review this.

Copy link
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.

I need to concentrate on the tests and there's currently construction outside my window so I'm sending the rest of the review early.


// CalculateExcess updates the excess value based on the target and current
// usage over the provided duration.
func CalculateExcess(
Copy link
Contributor

Choose a reason for hiding this comment

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

(style) You can collapse the parameter types instead of splitting over many lines.

func CalculateExcess(target, current, excess gas.Gas, duration uint64) {

(not just style) That said, I worry when there are too many of the same type in a row that the call site can become ambiguous and bugs slip in. Since these params are common to all three functions, should you maybe create a struct and make them methods on it?

type Context struct {
  Target, Current, Excess gas.Gas
}

func (c Context) CalculateExcess(duration uint64) gas.Gas {

Presumably you're going to pass the excess back in every time so the struct might actually work nicely because you can go one step further:

func (c *Context) UpdateExcess(duration uint64) { // note nothing returned

The name is fairly arbitrary but fee.Context felt accurate and descriptive.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The thing that is a bit weird is: target, minPrice, and excessConversionConstant are all essentially constants. current and excess are dynamic (and persisted to state). duration, maxDuration, and targetCost are the "real" arguments for these functions.

I had considered having:

type Config struct {
	Target                   gas.Gas
	MinPrice                 gas.Price
	ExcessConversionConstant gas.Gas
}

type State struct {
	Current gas.Gas
	Excess  gas.Gas
}

But then we'd need to be passing both of these into CalculateExcess... I'll try to play around with some things to see if I can find something I don't dislike

target gas.Gas,
current gas.Gas,
excess gas.Gas,
duration uint64,
Copy link
Contributor

Choose a reason for hiding this comment

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

The unit of duration is only known to be seconds once you look inside the code, so I'd just call it seconds everywhere.


// CalculateCost calculates how much to charge based on the dynamic fee
// mechanism for [duration].
func CalculateCost(
Copy link
Contributor

Choose a reason for hiding this comment

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

(optional) The Calculate prefix feels a bit redundant and reminds me of this recommendation re getters. If you change to the GasContext struct then you could call the method CostOf()?

price := gas.CalculatePrice(minPrice, excess, excessConversionConstant)
cost, err := safemath.Mul(duration, uint64(price))
if err != nil {
return math.MaxUint64
Copy link
Contributor

Choose a reason for hiding this comment

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

I'll see your error and raise you an exorbitant transaction cost!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is actually a discount! (because this is just capping the cost at MaxUint64 if it would have overflowed)

)
for i := uint64(0); i < duration; i++ {
excess = CalculateExcess(target, current, excess, 1)
// If the excess is 0, the price will remain constant for all future
Copy link
Contributor

Choose a reason for hiding this comment

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

This took a long time for me to grok (including asking you in a DM) so I think it's ok to be verbose in the explanation. Here's a first draft of my understanding that you can copy and tweak:

The change in excess is positively correlated with the difference between current and target. It is also strictly bounded in [0,2^64) with (under/over)flows being clipped. As we know that current != target, excess would be strictly increasing or strictly decreasing if it weren't for clipping. However, when coupled with clipping, this guarantees that a zero excess will remain zero for all future iterations.

return duration
}
}
return duration
Copy link
Contributor

Choose a reason for hiding this comment

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

This will always be maxDuration so I think it should be explicit.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is not always maxDuration. (The tests will fail if this is changed to maxDuration). The loop exits if duration == maxDuration or if cost >= targetCost

return duration
}

duration++
Copy link
Contributor

Choose a reason for hiding this comment

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

I think this should be in the loop. As far as I can tell, the only branch it would affect would be the error on safemath.Add(cost...).

for ; cost < targetCost && duration < maxDuration; duration++ {

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Having:

if err != nil {
	return duration + 1
}

feels pretty weird to me... Personally I find the code as-is more readable... but if you have conviction then I'm fine moving the increment around.

price := gas.CalculatePrice(minPrice, excess, excessConversionConstant)
cost, err = safemath.Add(cost, uint64(price))
if err != nil {
return duration
Copy link
Contributor

Choose a reason for hiding this comment

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

Why duration and not maxDuration?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We are looking for the duration where cost >= targetCost. cost would be pinned to MaxUint64 here - so we know that cost is >= any possible targetCost at this duration.

hour = 60 * minute
day = 24 * hour
week = 7 * day
year = 365 * day
Copy link
Contributor

Choose a reason for hiding this comment

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

Not 525,600 minutes?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not sure if this comment is implying that year should be set to 52 * week rather than 365 * day or not haha... But the rest of the P-chain uses this definition of years.

The chain doesn't really need to care about any leap seconds or leap years... We can set our own time cadence... We use this definition of a year for our maximum staking duration (among other things)

name string
target gas.Gas
current gas.Gas
excess gas.Gas
Copy link
Contributor

Choose a reason for hiding this comment

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

excessAtStart?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Been folded into state.

excess: 0,
duration: minute,
cost: 122_880,
excessAfterDuration: 0,
Copy link
Contributor

Choose a reason for hiding this comment

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

Because of no underflow? If so, please add that to the test name.

Copy link
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 test name is accurate... I added a comment on expectedExcess

excess gas.Gas
duration uint64
cost uint64
excessAfterDuration gas.Gas
Copy link
Contributor

Choose a reason for hiding this comment

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

(nit) want or expect prefix to signal early on to the reader. I had to go to the test implementation and then come back here to be sure that I understood.

The same probably applies to cost even though it's plugged into CalculateDuration because that function is effectively just an inversion.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I had avoided expected because they are used as both inputs and outputs... I switched to use expected and commented that they are used for both.

current: 10,
excess: 0,
duration: minute,
cost: 122_880,
Copy link
Contributor

Choose a reason for hiding this comment

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

(no action required) I'm working on the assumption that you've correctly calculated these from a canonical source.

Copy link
Contributor Author

@StephenButtolph StephenButtolph Sep 11, 2024

Choose a reason for hiding this comment

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

The "canonical source" is:

def cost_over_time(current:int, target:int, excess:int, Δt: int) -> int:
    cost = 0
    for _ in range(Δt):
        excess = max(excess + current - target, 0)
        cost += fake_exponential(minPrice, excess, conversionConstant)
    return cost

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Copy link
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.

LGTM!

} else if s.Current > target {
excess = excess.AddPerSecond(s.Current-target, seconds)
}
return State{
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this because we were talking about functional languages? 🤣

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's actually because we might be passing these through different block states... And we definitely don't want them to be mutable there

}

// CalculateDuration calculates the duration that it would take to charge at
// SecondsUntil calculates the number of seconds that it would take to charge at
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice naming! I love code that reads like prose.

@StephenButtolph StephenButtolph added this pull request to the merge queue Sep 12, 2024
Merged via the queue into master with commit df3b4fa Sep 12, 2024
20 of 21 checks passed
@StephenButtolph StephenButtolph deleted the implement-acp-77-fees branch September 12, 2024 16:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
Archived in project
Development

Successfully merging this pull request may close these issues.

5 participants