Skip to content
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

witness: only include elements if there's enough gas for it #495

Open
wants to merge 36 commits into
base: kaustinen-with-shapella
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
9f44b79
state: access witness with partial cost charging
jsign Sep 12, 2024
8b45fb7
fix most problems
jsign Sep 12, 2024
d46fa01
rebase fixes
jsign Sep 12, 2024
d9bab66
fixes
jsign Sep 16, 2024
3d79a1d
fixes
jsign Sep 16, 2024
59720c4
.
jsign Sep 16, 2024
49d84a2
remove 4762 gas call wrappers
jsign Sep 19, 2024
ed4e5e5
more progress
jsign Sep 19, 2024
4ecd713
call gas
jsign Sep 19, 2024
95f771d
fix
jsign Sep 19, 2024
81940b9
fix
jsign Sep 19, 2024
09be3aa
fix
jsign Sep 19, 2024
f147cdc
fix
jsign Sep 19, 2024
b2e2fb8
fix
jsign Sep 19, 2024
e98688e
fix
jsign Sep 19, 2024
f29b148
fix
jsign Sep 19, 2024
05e6092
fix
jsign Sep 19, 2024
fdb9c50
fix
jsign Sep 20, 2024
9ea37be
simplify warm costs
jsign Sep 23, 2024
83aca33
cleanup
jsign Sep 25, 2024
ffc1f2d
commit statedb
jsign Sep 27, 2024
a00d50a
add proper verkle flag
jsign Oct 1, 2024
85196cc
spec bugfixes (#505)
jsign Oct 8, 2024
8b106f2
selfdestruct: avoid BASIC_DATA inclusion for system contracts (#506)
jsign Oct 8, 2024
efabf95
ci: new workflows for testing (#504)
jsign Oct 8, 2024
51dca93
eip4762: change returned error for witness oog (#507)
jsign Oct 9, 2024
b32bca9
eip4762: move *CALL BASIC_DATA charging before reservation
jsign Oct 10, 2024
26452f4
CALL witness charge ordering change
jsign Oct 11, 2024
4d4eabc
criminal hacks
jsign Oct 12, 2024
f4733ce
fix: create init order redux (#510)
gballet Oct 15, 2024
4b91d84
Avoid unnecessary 100 gas cost in CALL & add missing write-event for …
jsign Oct 15, 2024
9635cc1
filling fixes
jsign Oct 15, 2024
1ec805c
More spec fixes (#511)
jsign Oct 16, 2024
b2b96a2
nit
jsign Oct 16, 2024
8057173
extra fix (#512)
jsign Oct 17, 2024
59426d9
avoid BASIC_DATA branch edit for free (#513)
jsign Oct 17, 2024
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
2 changes: 1 addition & 1 deletion cmd/evm/internal/t8ntool/execution.go
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ func (pre *Prestate) Apply(vmConfig vm.Config, chainConfig *params.ChainConfig,
// Amount is in gwei, turn into wei
amount := new(big.Int).Mul(new(big.Int).SetUint64(w.Amount), big.NewInt(params.GWei))
statedb.AddBalance(w.Address, amount)
statedb.Witness().TouchFullAccount(w.Address[:], true)
statedb.Witness().TouchFullAccount(w.Address[:], true, nil)
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

For any Touch*(...) call that doesn't care about charging gas, we send the nil UseGas(...) function.

This is the equivalent action of the previous API where we ignored the returned value (since we didn't charge that gas to anyone).

}
if chainConfig.IsVerkle(big.NewInt(int64(pre.Env.Number)), pre.Env.Timestamp) {
if err := overlay.OverlayVerkleTransition(statedb, common.Hash{}, chainConfig.OverlayStride); err != nil {
Expand Down
2 changes: 1 addition & 1 deletion consensus/beacon/consensus.go
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ func (beacon *Beacon) Finalize(chain consensus.ChainHeaderReader, header *types.
state.AddBalance(w.Address, amount)

// The returned gas is not charged
state.Witness().TouchFullAccount(w.Address[:], true)
state.Witness().TouchFullAccount(w.Address[:], true, nil)
}

if chain.Config().IsVerkle(header.Number, header.Time) {
Expand Down
170 changes: 83 additions & 87 deletions core/state/access_witness.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@

import (
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/common/math"
"github.com/ethereum/go-ethereum/params"
"github.com/ethereum/go-ethereum/trie/utils"
"github.com/holiman/uint256"
Expand All @@ -30,6 +29,9 @@
// * the second bit is set if the branch has been read
type mode byte

// UseGasFn is a function that can be used to charge gas for a given amount.
type UseGasFn func(uint64) bool
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Now Touch*(....) functions receive a function that we'll use as a callback to charge the required gas.

Many Touch*(...) do more than one charging. This means that this callback could be called multiple times.

Before I tried another idea which was not passing this callback, but asking for availableGas. The problem with that is that it adds a lot of strain in callers to solve partial charging. The Touch*(...) calls can charge for more than one thing. In the case the availableGas was sufficient for some of them but not all, that means the API should return:

  • gasThatMustBeCharged
  • and ok bool to signal if the availableGas was sufficient for everything or not.

The caller should:

  • Always charge gasThatMustBeCharged, respectively of what ok is. Since the passed availableGas was at least sufficient to put some stuff in the witness.
  • if ok is false, fail due to out of gas.

The above logic should be done in all the callers.

This proposed API where we pass a callback function, avoids all this logic in all callers. The API call will already charge for whatever was allowed, and simply return false signaling that the available gas was insufficient and let the caller fail (i.e: the caller shouldn't deal with charging stuff since that was already done in the API call).

That's the "TLDR" of this approach. I know was mentioned this still sounds like can get some pushback from the geth team, so we can try again with the other approach... but I found it a bit annoying.


const (
AccessWitnessReadFlag = mode(1)
AccessWitnessWriteFlag = mode(2)
Expand Down Expand Up @@ -89,132 +91,130 @@
return naw
}

func (aw *AccessWitness) TouchFullAccount(addr []byte, isWrite bool) uint64 {
var gas uint64
func (aw *AccessWitness) TouchFullAccount(addr []byte, isWrite bool, useGasFn UseGasFn) bool {
for i := utils.BasicDataLeafKey; i <= utils.CodeHashLeafKey; i++ {
gas += aw.touchAddressAndChargeGas(addr, zeroTreeIndex, byte(i), isWrite)
if _, ok := aw.touchAddressAndChargeGas(addr, zeroTreeIndex, byte(i), isWrite, useGasFn); !ok {
return false
}
}
return gas
return true
}

func (aw *AccessWitness) TouchAndChargeMessageCall(addr []byte) uint64 {
var gas uint64
gas += aw.touchAddressAndChargeGas(addr, zeroTreeIndex, utils.BasicDataLeafKey, false)
return gas
func (aw *AccessWitness) TouchAndChargeMessageCall(addr []byte, useGasFn UseGasFn) (uint64, bool) {
return aw.touchAddressAndChargeGas(addr, zeroTreeIndex, utils.BasicDataLeafKey, false, useGasFn)
}

func (aw *AccessWitness) TouchAndChargeValueTransfer(callerAddr, targetAddr []byte) uint64 {
var gas uint64
gas += aw.touchAddressAndChargeGas(callerAddr, zeroTreeIndex, utils.BasicDataLeafKey, true)
gas += aw.touchAddressAndChargeGas(targetAddr, zeroTreeIndex, utils.BasicDataLeafKey, true)
return gas
func (aw *AccessWitness) TouchAndChargeValueTransfer(callerAddr, targetAddr []byte, useGasFn UseGasFn) bool {
_, ok := aw.touchAddressAndChargeGas(callerAddr, zeroTreeIndex, utils.BasicDataLeafKey, true, useGasFn)
if !ok {
return false
}
_, ok = aw.touchAddressAndChargeGas(targetAddr, zeroTreeIndex, utils.BasicDataLeafKey, true, useGasFn)
return ok
}

// TouchAndChargeContractCreateCheck charges access costs before
// a contract creation is initiated. It is just reads, because the
// address collision is done before the transfer, and so no write
// are guaranteed to happen at this point.
func (aw *AccessWitness) TouchAndChargeContractCreateCheck(addr []byte) uint64 {
var gas uint64
gas += aw.touchAddressAndChargeGas(addr, zeroTreeIndex, utils.BasicDataLeafKey, false)
gas += aw.touchAddressAndChargeGas(addr, zeroTreeIndex, utils.CodeHashLeafKey, false)
return gas
func (aw *AccessWitness) TouchAndChargeContractCreateCheck(addr []byte, useGasFn UseGasFn) bool {
if _, ok := aw.touchAddressAndChargeGas(addr, zeroTreeIndex, utils.BasicDataLeafKey, false, useGasFn); !ok {
return false
}
_, ok := aw.touchAddressAndChargeGas(addr, zeroTreeIndex, utils.CodeHashLeafKey, false, useGasFn)
return ok
}

// TouchAndChargeContractCreateInit charges access costs to initiate
// a contract creation.
func (aw *AccessWitness) TouchAndChargeContractCreateInit(addr []byte) uint64 {
var gas uint64
gas += aw.touchAddressAndChargeGas(addr, zeroTreeIndex, utils.BasicDataLeafKey, true)
gas += aw.touchAddressAndChargeGas(addr, zeroTreeIndex, utils.CodeHashLeafKey, true)
return gas
func (aw *AccessWitness) TouchAndChargeContractCreateInit(addr []byte, useGasFn UseGasFn) bool {
if _, ok := aw.touchAddressAndChargeGas(addr, zeroTreeIndex, utils.BasicDataLeafKey, true, useGasFn); !ok {
return false
}
_, ok := aw.touchAddressAndChargeGas(addr, zeroTreeIndex, utils.CodeHashLeafKey, true, useGasFn)
return ok
}

func (aw *AccessWitness) TouchTxOriginAndComputeGas(originAddr []byte) uint64 {
func (aw *AccessWitness) TouchTxOriginAndComputeGas(originAddr []byte) {
for i := utils.BasicDataLeafKey; i <= utils.CodeHashLeafKey; i++ {
aw.touchAddressAndChargeGas(originAddr, zeroTreeIndex, byte(i), i == utils.BasicDataLeafKey)
aw.touchAddressAndChargeGas(originAddr, zeroTreeIndex, byte(i), i == utils.BasicDataLeafKey, nil)
}

// Kaustinen note: we're currently experimenting with stop chargin gas for the origin address
// so simple transfer still take 21000 gas. This is to potentially avoid breaking existing tooling.
// This is the reason why we return 0 instead of `gas`.
// Note that we still have to touch the addresses to make sure the witness is correct.
return 0
}

func (aw *AccessWitness) TouchTxExistingAndComputeGas(targetAddr []byte, sendsValue bool) uint64 {
aw.touchAddressAndChargeGas(targetAddr, zeroTreeIndex, utils.BasicDataLeafKey, sendsValue)
aw.touchAddressAndChargeGas(targetAddr, zeroTreeIndex, utils.CodeHashLeafKey, false)

// Kaustinen note: we're currently experimenting with stop chargin gas for the origin address
// so simple transfer still take 21000 gas. This is to potentially avoid breaking existing tooling.
// This is the reason why we return 0 instead of `gas`.
// Note that we still have to touch the addresses to make sure the witness is correct.
return 0
func (aw *AccessWitness) TouchTxExistingAndComputeGas(targetAddr []byte, sendsValue bool) {
aw.touchAddressAndChargeGas(targetAddr, zeroTreeIndex, utils.BasicDataLeafKey, sendsValue, nil)
aw.touchAddressAndChargeGas(targetAddr, zeroTreeIndex, utils.CodeHashLeafKey, false, nil)
}

func (aw *AccessWitness) TouchSlotAndChargeGas(addr []byte, slot common.Hash, isWrite bool) uint64 {
func (aw *AccessWitness) TouchSlotAndChargeGas(addr []byte, slot common.Hash, isWrite bool, useGasFn UseGasFn) (uint64, bool) {
treeIndex, subIndex := utils.GetTreeKeyStorageSlotTreeIndexes(slot.Bytes())
return aw.touchAddressAndChargeGas(addr, *treeIndex, subIndex, isWrite)
}

func (aw *AccessWitness) touchAddressAndChargeGas(addr []byte, treeIndex uint256.Int, subIndex byte, isWrite bool) uint64 {
stemRead, selectorRead, stemWrite, selectorWrite, selectorFill := aw.touchAddress(addr, treeIndex, subIndex, isWrite)

var gas uint64
if stemRead {
gas += params.WitnessBranchReadCost
}
if selectorRead {
gas += params.WitnessChunkReadCost
}
if stemWrite {
gas += params.WitnessBranchWriteCost
}
if selectorWrite {
gas += params.WitnessChunkWriteCost
}
if selectorFill {
gas += params.WitnessChunkFillCost
}

return gas
return aw.touchAddressAndChargeGas(addr, *treeIndex, subIndex, isWrite, useGasFn)
}

// touchAddress adds any missing access event to the witness.
func (aw *AccessWitness) touchAddress(addr []byte, treeIndex uint256.Int, subIndex byte, isWrite bool) (bool, bool, bool, bool, bool) {
func (aw *AccessWitness) touchAddressAndChargeGas(addr []byte, treeIndex uint256.Int, subIndex byte, isWrite bool, useGasFn UseGasFn) (uint64, bool) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is the new "base" fundamental method that charges for stuff.
Basically:

  1. we fill the boolanes if this charging required branchRead, chunkRead, etc. But we don't yet affect aw.[branches|chunks] since we want to do that ONLY if we have available gas.

(Continue to read below comments for further bullets)

branchKey := newBranchAccessKey(addr, treeIndex)
chunkKey := newChunkAccessKey(branchKey, subIndex)

// Read access.
var branchRead, chunkRead bool
if _, hasStem := aw.branches[branchKey]; !hasStem {
branchRead = true
aw.branches[branchKey] = AccessWitnessReadFlag
}
if _, hasSelector := aw.chunks[chunkKey]; !hasSelector {
chunkRead = true
aw.chunks[chunkKey] = AccessWitnessReadFlag
}

// Write access.
var branchWrite, chunkWrite, chunkFill bool
if isWrite {
if (aw.branches[branchKey] & AccessWitnessWriteFlag) == 0 {
branchWrite = true
aw.branches[branchKey] |= AccessWitnessWriteFlag
}

chunkValue := aw.chunks[chunkKey]
if (chunkValue & AccessWitnessWriteFlag) == 0 {
chunkWrite = true
aw.chunks[chunkKey] |= AccessWitnessWriteFlag
}
}

// TODO: charge chunk filling costs if the leaf was previously empty in the state
var gas uint64
if branchRead {
gas += params.WitnessBranchReadCost
}
if chunkRead {
gas += params.WitnessChunkReadCost
}
if branchWrite {
gas += params.WitnessBranchWriteCost
}
if chunkWrite {
gas += params.WitnessChunkWriteCost
}
if chunkFill {
gas += params.WitnessChunkFillCost
}
Comment on lines +182 to +197
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

  1. With the booleans, we calcualte the gas as usual.


if useGasFn != nil {
if ok := useGasFn(gas); !ok {
return 0, false
}
}
Comment on lines +199 to +203
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

  1. Ask for the callback to charge this gas, and signal us if it was possible or not.

If wasn't possible, return early. Notice that we haven't technically added anything to the witness. This is important.


if branchRead {
aw.branches[branchKey] = AccessWitnessReadFlag
}
if branchWrite {
aw.branches[branchKey] |= AccessWitnessWriteFlag
}
if chunkRead {
aw.chunks[chunkKey] = AccessWitnessReadFlag
}
if chunkWrite {
chunkWrite = true

Check failure on line 213 in core/state/access_witness.go

View workflow job for this annotation

GitHub Actions / lint

ineffectual assignment to chunkWrite (ineffassign)
aw.chunks[chunkKey] |= AccessWitnessWriteFlag
}
Comment on lines +205 to 217
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

  1. Only after we know we could charge for the gas, then add it to the witness.


return branchRead, chunkRead, branchWrite, chunkWrite, chunkFill
return gas, true
}

type branchAccessKey struct {
Expand Down Expand Up @@ -242,15 +242,15 @@
}

// touchCodeChunksRangeOnReadAndChargeGas is a helper function to touch every chunk in a code range and charge witness gas costs
func (aw *AccessWitness) TouchCodeChunksRangeAndChargeGas(contractAddr []byte, startPC, size uint64, codeLen uint64, isWrite bool) uint64 {
func (aw *AccessWitness) TouchCodeChunksRangeAndChargeGas(contractAddr []byte, startPC, size uint64, codeLen uint64, isWrite bool, useGasFn UseGasFn) bool {
// note that in the case where the copied code is outside the range of the
// contract code but touches the last leaf with contract code in it,
// we don't include the last leaf of code in the AccessWitness. The
// reason that we do not need the last leaf is the account's code size
// is already in the AccessWitness so a stateless verifier can see that
// the code from the last leaf is not needed.
if size == 0 || startPC >= codeLen {
return 0
return true
}

endPC := startPC + size
Expand All @@ -261,25 +261,21 @@
endPC -= 1 // endPC is the last bytecode that will be touched.
}

var statelessGasCharged uint64
for chunkNumber := startPC / 31; chunkNumber <= endPC/31; chunkNumber++ {
treeIndex := *uint256.NewInt((chunkNumber + 128) / 256)
subIndex := byte((chunkNumber + 128) % 256)
gas := aw.touchAddressAndChargeGas(contractAddr, treeIndex, subIndex, isWrite)
var overflow bool
statelessGasCharged, overflow = math.SafeAdd(statelessGasCharged, gas)
if overflow {
panic("overflow when adding gas")
if _, ok := aw.touchAddressAndChargeGas(contractAddr, treeIndex, subIndex, isWrite, useGasFn); !ok {
return false
}
}

return statelessGasCharged
return true
}

func (aw *AccessWitness) TouchBasicData(addr []byte, isWrite bool) uint64 {
return aw.touchAddressAndChargeGas(addr, zeroTreeIndex, utils.BasicDataLeafKey, isWrite)
func (aw *AccessWitness) TouchBasicData(addr []byte, isWrite bool, useGasFn UseGasFn) (uint64, bool) {
return aw.touchAddressAndChargeGas(addr, zeroTreeIndex, utils.BasicDataLeafKey, isWrite, useGasFn)
}

func (aw *AccessWitness) TouchCodeHash(addr []byte, isWrite bool) uint64 {
return aw.touchAddressAndChargeGas(addr, zeroTreeIndex, utils.CodeHashLeafKey, isWrite)
func (aw *AccessWitness) TouchCodeHash(addr []byte, isWrite bool, useGasFn UseGasFn) (uint64, bool) {
return aw.touchAddressAndChargeGas(addr, zeroTreeIndex, utils.CodeHashLeafKey, isWrite, useGasFn)
}
4 changes: 2 additions & 2 deletions core/state_processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ func ApplyTransaction(config *params.ChainConfig, bc ChainContext, author *commo

func InsertBlockHashHistoryAtEip2935Fork(statedb *state.StateDB, prevNumber uint64, prevHash common.Hash, chain consensus.ChainHeaderReader) {
// Make sure that the historical contract is added to the witness
statedb.Witness().TouchFullAccount(params.HistoryStorageAddress[:], true)
statedb.Witness().TouchFullAccount(params.HistoryStorageAddress[:], true, nil)

ancestor := chain.GetHeader(prevHash, prevNumber)
for i := prevNumber; i > 0 && i >= prevNumber-params.Eip2935BlockHashHistorySize; i-- {
Expand All @@ -191,5 +191,5 @@ func ProcessParentBlockHash(statedb *state.StateDB, prevNumber uint64, prevHash
var key common.Hash
binary.BigEndian.PutUint64(key[24:], ringIndex)
statedb.SetState(params.HistoryStorageAddress, key, prevHash)
statedb.Witness().TouchSlotAndChargeGas(params.HistoryStorageAddress[:], key, true)
statedb.Witness().TouchSlotAndChargeGas(params.HistoryStorageAddress[:], key, true, nil)
}
25 changes: 3 additions & 22 deletions core/state_transition.go
Original file line number Diff line number Diff line change
Expand Up @@ -339,19 +339,6 @@ func (st *StateTransition) preCheck() error {
return st.buyGas()
}

// tryConsumeGas tries to subtract gas from gasPool, setting the result in gasPool
// if subtracting more gas than remains in gasPool, set gasPool = 0 and return false
// otherwise, do the subtraction setting the result in gasPool and return true
func tryConsumeGas(gasPool *uint64, gas uint64) bool {
if *gasPool < gas {
*gasPool = 0
return false
}

*gasPool -= gas
return true
}

// TransitionDb will transition the state by applying the current message and
// returning the evm execution result with following fields.
//
Expand Down Expand Up @@ -406,16 +393,10 @@ func (st *StateTransition) TransitionDb() (*ExecutionResult, error) {
targetAddr := msg.To
originAddr := msg.From

statelessGasOrigin := st.evm.Accesses.TouchTxOriginAndComputeGas(originAddr.Bytes())
if !tryConsumeGas(&st.gasRemaining, statelessGasOrigin) {
return nil, fmt.Errorf("%w: Insufficient funds to cover witness access costs for transaction: have %d, want %d", ErrInsufficientBalanceWitness, st.gasRemaining, gas)
}
st.evm.Accesses.TouchTxOriginAndComputeGas(originAddr.Bytes())

if msg.To != nil {
statelessGasDest := st.evm.Accesses.TouchTxExistingAndComputeGas(targetAddr.Bytes(), msg.Value.Sign() != 0)
if !tryConsumeGas(&st.gasRemaining, statelessGasDest) {
return nil, fmt.Errorf("%w: Insufficient funds to cover witness access costs for transaction: have %d, want %d", ErrInsufficientBalanceWitness, st.gasRemaining, gas)
}
st.evm.Accesses.TouchTxExistingAndComputeGas(targetAddr.Bytes(), msg.Value.Sign() != 0)
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I simplified this a bit. No need to tryConsumeGas since those touching always return 0. This is already in the spec.


// ensure the code size ends up in the access witness
st.evm.StateDB.GetCodeSize(*targetAddr)
Expand Down Expand Up @@ -472,7 +453,7 @@ func (st *StateTransition) TransitionDb() (*ExecutionResult, error) {

// add the coinbase to the witness iff the fee is greater than 0
if rules.IsEIP4762 && fee.Sign() != 0 {
st.evm.Accesses.TouchFullAccount(st.evm.Context.Coinbase[:], true)
st.evm.Accesses.TouchFullAccount(st.evm.Context.Coinbase[:], true, nil)
}
}

Expand Down
12 changes: 12 additions & 0 deletions core/vm/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,15 @@ func allZero(b []byte) bool {
}
return true
}

type gasConsumer struct {
availableGas uint64
}

func (gc *gasConsumer) consumeGas(gas uint64) bool {
if gc.availableGas < gas {
return false
}
gc.availableGas -= gas
return true
}
Comment on lines +96 to +106
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is a helper only required in some places. I'll explain on first use.

Loading
Loading