-
Notifications
You must be signed in to change notification settings - Fork 807
Implement ACP-77 fee calculations #3367
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
Signed-off-by: Dhruba Basu <7675102+dhrubabasu@users.noreply.github.com>
TODO: Add dhruba as co-author. |
There was a problem hiding this 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.
vms/platformvm/validators/fee/fee.go
Outdated
|
||
// CalculateExcess updates the excess value based on the target and current | ||
// usage over the provided duration. | ||
func CalculateExcess( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
vms/platformvm/validators/fee/fee.go
Outdated
target gas.Gas, | ||
current gas.Gas, | ||
excess gas.Gas, | ||
duration uint64, |
There was a problem hiding this comment.
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.
vms/platformvm/validators/fee/fee.go
Outdated
|
||
// CalculateCost calculates how much to charge based on the dynamic fee | ||
// mechanism for [duration]. | ||
func CalculateCost( |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
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)
vms/platformvm/validators/fee/fee.go
Outdated
) | ||
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 |
There was a problem hiding this comment.
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.
vms/platformvm/validators/fee/fee.go
Outdated
return duration | ||
} | ||
} | ||
return duration |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
vms/platformvm/validators/fee/fee.go
Outdated
return duration | ||
} | ||
|
||
duration++ |
There was a problem hiding this comment.
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++ {
There was a problem hiding this comment.
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.
vms/platformvm/validators/fee/fee.go
Outdated
price := gas.CalculatePrice(minPrice, excess, excessConversionConstant) | ||
cost, err = safemath.Add(cost, uint64(price)) | ||
if err != nil { | ||
return duration |
There was a problem hiding this comment.
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
?
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not 525,600 minutes?
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
excessAtStart
?
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this 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{ |
There was a problem hiding this comment.
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? 🤣
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
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 thecapacity
,target
, andminimumTimeToDouble
.How this was tested
CalculateCost
andCalculateDuration
have "naive" implementations that were used to fuzz the optimized implementations.