From d93a9bcb0057e5b93bfa99f6af092edf2632a291 Mon Sep 17 00:00:00 2001 From: Janez Podhostnik Date: Mon, 13 Jul 2026 16:57:45 +0200 Subject: [PATCH 1/3] Unify RunWithMeteringDisabled to skip ledger interaction metering --- fvm/fvm_blockcontext_test.go | 5 +- fvm/meter/interaction_meter.go | 20 +++--- fvm/meter/interaction_meter_test.go | 8 +-- fvm/meter/meter_test.go | 87 +++++------------------ fvm/storage/state/execution_state.go | 27 +++++-- fvm/storage/state/execution_state_test.go | 57 +++++++++++++++ fvm/storage/state/transaction_state.go | 5 +- 7 files changed, 118 insertions(+), 91 deletions(-) diff --git a/fvm/fvm_blockcontext_test.go b/fvm/fvm_blockcontext_test.go index 08df5c3c058..bc039702e19 100644 --- a/fvm/fvm_blockcontext_test.go +++ b/fvm/fvm_blockcontext_test.go @@ -1299,10 +1299,13 @@ func TestBlockContext_ExecuteTransaction_InteractionLimitReached(t *testing.T) { chain) require.NoError(t, err) + // The account count is sized so that the metered interaction + // exceeds MaxStateInteractionSize, triggering the interaction + // limit from within Cadence execution. _, txBodyBuilder := testutil.CreateMultiAccountCreationTransaction( t, chain, - 40) + 60) txBodyBuilder.SetProposalKey(chain.ServiceAddress(), 0, 0). SetPayer(accounts[0]) diff --git a/fvm/meter/interaction_meter.go b/fvm/meter/interaction_meter.go index 7abc2ffc10e..2d725d37668 100644 --- a/fvm/meter/interaction_meter.go +++ b/fvm/meter/interaction_meter.go @@ -52,14 +52,18 @@ func NewInteractionMeter(params InteractionMeterParameters) InteractionMeter { // MeterStorageRead captures storage read bytes count. // +// Only the first read of a given register is counted; later reads are treated +// as free cache hits. Consequently, if a read is not recorded here (e.g. +// because it happened while metering was disabled), a subsequent recorded read +// of the same register is counted as a fresh storage read. +// // Expected error returns during normal operation: // - [errors.LimitExceededError] with [errors.LimitKindLedgerInteraction] if // the total bytes of storage interactions (reads + writes) exceeds the -// storage interaction limit and enforceLimit is true +// storage interaction limit func (m *InteractionMeter) MeterStorageRead( storageKey flow.RegisterID, value flow.RegisterValue, - enforceLimit bool, ) error { // all reads are on a View which only read from storage at the first read of a given key @@ -69,7 +73,7 @@ func (m *InteractionMeter) MeterStorageRead( m.reads[storageKey] = readByteSize } - return m.checkStorageInteractionLimit(enforceLimit) + return m.checkStorageInteractionLimit() } // MeterStorageWrite captures storage written bytes count. @@ -80,11 +84,10 @@ func (m *InteractionMeter) MeterStorageRead( // Expected error returns during normal operation: // - [errors.LimitExceededError] with [errors.LimitKindLedgerInteraction] if // the total bytes of storage interactions (reads + writes) exceeds the -// storage interaction limit and enforceLimit is true +// storage interaction limit func (m *InteractionMeter) MeterStorageWrite( storageKey flow.RegisterID, value flow.RegisterValue, - enforceLimit bool, ) error { updateSize := getStorageKeyValueSize(storageKey, value) m.replaceWrite(storageKey, updateSize) @@ -95,7 +98,7 @@ func (m *InteractionMeter) MeterStorageWrite( m.reads[storageKey] = 0 } - return m.checkStorageInteractionLimit(enforceLimit) + return m.checkStorageInteractionLimit() } // replaceWrite replaces the write size of a given key with the new size, because @@ -128,9 +131,8 @@ func (m *InteractionMeter) replaceWrite( m.totalStorageBytesWritten += newSize } -func (m *InteractionMeter) checkStorageInteractionLimit(enforceLimit bool) error { - if enforceLimit && - m.TotalBytesOfStorageInteractions() > m.params.storageInteractionLimit { +func (m *InteractionMeter) checkStorageInteractionLimit() error { + if m.TotalBytesOfStorageInteractions() > m.params.storageInteractionLimit { return errors.NewLimitExceededError( errors.LimitKindLedgerInteraction, m.params.storageInteractionLimit) diff --git a/fvm/meter/interaction_meter_test.go b/fvm/meter/interaction_meter_test.go index 0113ec200a8..4fc3b22dfd2 100644 --- a/fvm/meter/interaction_meter_test.go +++ b/fvm/meter/interaction_meter_test.go @@ -219,22 +219,22 @@ func TestInteractionMeter_Merge(t *testing.T) { var err error if c.ParentReads != nil { - err = parentMeter.MeterStorageRead(key, c.ParentReads, false) + err = parentMeter.MeterStorageRead(key, c.ParentReads) require.NoError(t, err) } if c.ChildReads != nil { - err = childMeter.MeterStorageRead(key, c.ChildReads, false) + err = childMeter.MeterStorageRead(key, c.ChildReads) require.NoError(t, err) } if c.ParentWrites != nil { - err = parentMeter.MeterStorageWrite(key, c.ParentWrites, false) + err = parentMeter.MeterStorageWrite(key, c.ParentWrites) require.NoError(t, err) } if c.ChildWrites != nil { - err = childMeter.MeterStorageWrite(key, c.ChildWrites, false) + err = childMeter.MeterStorageWrite(key, c.ChildWrites) require.NoError(t, err) } diff --git a/fvm/meter/meter_test.go b/fvm/meter/meter_test.go index 74ebf03c048..d73f6b337b5 100644 --- a/fvm/meter/meter_test.go +++ b/fvm/meter/meter_test.go @@ -661,12 +661,12 @@ func TestStorageLimits(t *testing.T) { size1 := meter.GetStorageKeyValueSizeForTesting(key1, val1) // first read of key1 - err := meter1.MeterStorageRead(key1, val1, false) + err := meter1.MeterStorageRead(key1, val1) require.NoError(t, err) require.Equal(t, meter1.TotalBytesReadFromStorage(), size1) // second read of key1 - err = meter1.MeterStorageRead(key1, val1, false) + err = meter1.MeterStorageRead(key1, val1) require.NoError(t, err) require.Equal(t, meter1.TotalBytesReadFromStorage(), size1) @@ -675,7 +675,7 @@ func TestStorageLimits(t *testing.T) { val2 := []byte{0x3, 0x2, 0x1} size2 := meter.GetStorageKeyValueSizeForTesting(key2, val2) - err = meter1.MeterStorageRead(key2, val2, false) + err = meter1.MeterStorageRead(key2, val2) require.NoError(t, err) require.Equal(t, meter1.TotalBytesReadFromStorage(), size1+size2) }) @@ -690,36 +690,23 @@ func TestStorageLimits(t *testing.T) { val2 := []byte{0x1, 0x2, 0x3, 0x4} // first write of key1 - err := meter1.MeterStorageWrite(key1, val1, false) + err := meter1.MeterStorageWrite(key1, val1) require.NoError(t, err) require.Equal(t, meter1.TotalBytesWrittenToStorage(), meter.GetStorageKeyValueSizeForTesting(key1, val1)) // second write of key1 with val2 - err = meter1.MeterStorageWrite(key1, val2, false) + err = meter1.MeterStorageWrite(key1, val2) require.NoError(t, err) require.Equal(t, meter1.TotalBytesWrittenToStorage(), meter.GetStorageKeyValueSizeForTesting(key1, val2)) // first write of key2 key2 := flow.NewRegisterID(flow.EmptyAddress, "2") - err = meter1.MeterStorageWrite(key2, val2, false) + err = meter1.MeterStorageWrite(key2, val2) require.NoError(t, err) require.Equal(t, meter1.TotalBytesWrittenToStorage(), meter.GetStorageKeyValueSizeForTesting(key1, val2)+meter.GetStorageKeyValueSizeForTesting(key2, val2)) }) - t.Run("metering storage read - exceeding limit - not enforced", func(t *testing.T) { - meter1 := meter.NewMeter( - meter.DefaultParameters().WithStorageInteractionLimit(1), - ) - - key1 := flow.NewRegisterID(flow.EmptyAddress, "1") - val1 := []byte{0x1, 0x2, 0x3} - - err := meter1.MeterStorageRead(key1, val1, false /* not enforced */) - require.NoError(t, err) - require.Equal(t, meter1.TotalBytesReadFromStorage(), meter.GetStorageKeyValueSizeForTesting(key1, val1)) - }) - t.Run("metering storage read - exceeding limit - enforced", func(t *testing.T) { testLimit := uint64(1) meter1 := meter.NewMeter( @@ -729,24 +716,11 @@ func TestStorageLimits(t *testing.T) { key1 := flow.NewRegisterID(flow.EmptyAddress, "1") val1 := []byte{0x1, 0x2, 0x3} - err := meter1.MeterStorageRead(key1, val1, true /* enforced */) + err := meter1.MeterStorageRead(key1, val1) unittest.RequireLimitExceededError(t, err, errors.LimitKindLedgerInteraction, testLimit) }) - t.Run("metering storage written - exceeding limit - not enforced", func(t *testing.T) { - testLimit := uint64(1) - meter1 := meter.NewMeter( - meter.DefaultParameters().WithStorageInteractionLimit(testLimit), - ) - - key1 := flow.NewRegisterID(flow.EmptyAddress, "1") - val1 := []byte{0x1, 0x2, 0x3} - - err := meter1.MeterStorageWrite(key1, val1, false /* not enforced */) - require.NoError(t, err) - }) - t.Run("metering storage written - exceeding limit - enforced", func(t *testing.T) { testLimit := uint64(1) meter1 := meter.NewMeter( @@ -756,7 +730,7 @@ func TestStorageLimits(t *testing.T) { key1 := flow.NewRegisterID(flow.EmptyAddress, "1") val1 := []byte{0x1, 0x2, 0x3} - err := meter1.MeterStorageWrite(key1, val1, true /* enforced */) + err := meter1.MeterStorageWrite(key1, val1) unittest.RequireLimitExceededError(t, err, errors.LimitKindLedgerInteraction, testLimit) }) @@ -774,38 +748,13 @@ func TestStorageLimits(t *testing.T) { size2 := meter.GetStorageKeyValueSizeForTesting(key2, val2) // read of key1 - err := meter1.MeterStorageRead(key1, val1, false) - require.NoError(t, err) - require.Equal(t, meter1.TotalBytesReadFromStorage(), size1) - require.Equal(t, meter1.TotalBytesOfStorageInteractions(), size1) - - // write of key2 - err = meter1.MeterStorageWrite(key2, val2, false) - require.NoError(t, err) - require.Equal(t, meter1.TotalBytesWrittenToStorage(), size2) - require.Equal(t, meter1.TotalBytesOfStorageInteractions(), size1+size2) - }) - - t.Run("metering storage read and written - exceeding limit - not enforced", func(t *testing.T) { - key1 := flow.NewRegisterID(flow.EmptyAddress, "1") - key2 := flow.NewRegisterID(flow.EmptyAddress, "2") - val1 := []byte{0x1, 0x2, 0x3} - val2 := []byte{0x1, 0x2, 0x3, 0x4} - size1 := meter.GetStorageKeyValueSizeForTesting(key1, val1) - size2 := meter.GetStorageKeyValueSizeForTesting(key2, val2) - - meter1 := meter.NewMeter( - meter.DefaultParameters().WithStorageInteractionLimit(size1 + size2 - 1), - ) - - // read of key1 - err := meter1.MeterStorageRead(key1, val1, false) + err := meter1.MeterStorageRead(key1, val1) require.NoError(t, err) require.Equal(t, meter1.TotalBytesReadFromStorage(), size1) require.Equal(t, meter1.TotalBytesOfStorageInteractions(), size1) // write of key2 - err = meter1.MeterStorageWrite(key2, val2, false) + err = meter1.MeterStorageWrite(key2, val2) require.NoError(t, err) require.Equal(t, meter1.TotalBytesWrittenToStorage(), size2) require.Equal(t, meter1.TotalBytesOfStorageInteractions(), size1+size2) @@ -824,13 +773,13 @@ func TestStorageLimits(t *testing.T) { ) // read of key1 - err := meter1.MeterStorageRead(key1, val1, true) + err := meter1.MeterStorageRead(key1, val1) require.NoError(t, err) require.Equal(t, meter1.TotalBytesReadFromStorage(), size1) require.Equal(t, meter1.TotalBytesOfStorageInteractions(), size1) // write of key2 - err = meter1.MeterStorageWrite(key2, val2, true) + err = meter1.MeterStorageWrite(key2, val2) unittest.RequireLimitExceededError(t, err, errors.LimitKindLedgerInteraction, testLimit) }) @@ -842,13 +791,13 @@ func TestStorageLimits(t *testing.T) { readKey1 := flow.NewRegisterID(flow.EmptyAddress, "r1") readVal1 := []byte{0x1, 0x2, 0x3} readSize1 := meter.GetStorageKeyValueSizeForTesting(readKey1, readVal1) - err := meter1.MeterStorageRead(readKey1, readVal1, false) + err := meter1.MeterStorageRead(readKey1, readVal1) require.NoError(t, err) writeKey1 := flow.NewRegisterID(flow.EmptyAddress, "w1") writeVal1 := []byte{0x1, 0x2, 0x3, 0x4} writeSize1 := meter.GetStorageKeyValueSizeForTesting(writeKey1, writeVal1) - err = meter1.MeterStorageWrite(writeKey1, writeVal1, false) + err = meter1.MeterStorageWrite(writeKey1, writeVal1) require.NoError(t, err) // meter 2 @@ -860,17 +809,17 @@ func TestStorageLimits(t *testing.T) { writeVal2 := []byte{0x1, 0x2, 0x3, 0x4, 0x5} writeSize2 := meter.GetStorageKeyValueSizeForTesting(writeKey2, writeVal2) - err = meter1.MeterStorageRead(readKey1, readVal1, false) + err = meter1.MeterStorageRead(readKey1, readVal1) require.NoError(t, err) - err = meter1.MeterStorageWrite(writeKey1, writeVal1, false) + err = meter1.MeterStorageWrite(writeKey1, writeVal1) require.NoError(t, err) // read the same key value as meter1 - err = meter2.MeterStorageRead(readKey1, readVal1, false) + err = meter2.MeterStorageRead(readKey1, readVal1) require.NoError(t, err) - err = meter2.MeterStorageWrite(writeKey2, writeVal2, false) + err = meter2.MeterStorageWrite(writeKey2, writeVal2) require.NoError(t, err) // merge diff --git a/fvm/storage/state/execution_state.go b/fvm/storage/state/execution_state.go index 80458d96ce3..55a724b3b19 100644 --- a/fvm/storage/state/execution_state.go +++ b/fvm/storage/state/execution_state.go @@ -94,6 +94,10 @@ func newLimitsController(params StateParameters) *limitsController { } } +// RunWithMeteringDisabled runs f with metering disabled. While metering is +// disabled, none of the metered quantities (computation, memory, events, and +// ledger interaction) are accumulated or limited. The previous metering state +// is restored afterwards, so nested calls behave correctly. func (controller *limitsController) RunWithMeteringDisabled(f func()) { if f == nil { return @@ -167,8 +171,9 @@ func (state *ExecutionState) DropChanges() error { return state.spockState.DropChanges() } -// Get returns a register value given owner and key. Limits are only enforced -// when metering is enabled. +// Get returns a register value given owner and key. Storage interaction is only +// metered (accumulated and limited) when metering is enabled; when metering is +// disabled the read is neither counted nor limited. // // Expected error returns during normal operation: // - [errors.StateKeySizeLimitError] if the key exceeds the key size limit @@ -197,12 +202,17 @@ func (state *ExecutionState) Get(id flow.RegisterID) (flow.RegisterValue, error) return nil, fmt.Errorf("failed to read %s: %w", id, getError) } - err = state.meter.MeterStorageRead(id, value, state.meteringEnabled) - return value, err + if state.meteringEnabled { + if err = state.meter.MeterStorageRead(id, value); err != nil { + return value, err + } + } + return value, nil } -// Set updates state delta with a register update. Limits are only enforced -// when metering is enabled. +// Set updates state delta with a register update. Storage interaction is only +// metered (accumulated and limited) when metering is enabled; when metering is +// disabled the write is neither counted nor limited. // // Expected error returns during normal operation: // - [errors.StateKeySizeLimitError] or [errors.StateValueSizeLimitError] if @@ -229,7 +239,10 @@ func (state *ExecutionState) Set(id flow.RegisterID, value flow.RegisterValue) e return fmt.Errorf("failed to update %s: %w", id, setError) } - return state.meter.MeterStorageWrite(id, value, state.meteringEnabled) + if state.meteringEnabled { + return state.meter.MeterStorageWrite(id, value) + } + return nil } // MeterComputation meters computation usage. It is a no-op if metering is diff --git a/fvm/storage/state/execution_state_test.go b/fvm/storage/state/execution_state_test.go index 29961fa7e98..6b2f3883c5c 100644 --- a/fvm/storage/state/execution_state_test.go +++ b/fvm/storage/state/execution_state_test.go @@ -255,3 +255,60 @@ func TestExecutionState_MaxInteraction(t *testing.T) { st.InteractionUsed(), key1Size+value1Size+key2Size+key4Size) } + +// TestExecutionState_MeteringDisabled verifies that ledger interaction behaves +// like computation/memory/events under RunWithMeteringDisabled: storage reads +// and writes performed inside the disabled block are neither accumulated toward +// the interaction total nor subject to the interaction limit. +func TestExecutionState_MeteringDisabled(t *testing.T) { + key1 := flow.NewRegisterID(unittest.RandomAddressFixture(), "1") + key1Size := uint64(8 + 1) + value1 := []byte("A") + value1Size := uint64(1) + + key2 := flow.NewRegisterID(unittest.RandomAddressFixture(), "2") + value2 := []byte("B") + + t.Run("interaction is not accumulated while metering is disabled", func(t *testing.T) { + st := state.NewExecutionState( + nil, + state.DefaultParameters(), + ) + + // baseline metered read is accumulated + _, err := st.Get(key1) + require.NoError(t, err) + require.Equal(t, key1Size, st.InteractionUsed()) + + // reads and writes inside the disabled block are not accumulated + st.RunWithMeteringDisabled(func() { + _, gErr := st.Get(key2) + require.NoError(t, gErr) + sErr := st.Set(key2, value2) + require.NoError(t, sErr) + }) + require.Equal(t, key1Size, st.InteractionUsed()) + }) + + t.Run("interaction limit is not enforced while metering is disabled", func(t *testing.T) { + // limit smaller than a single write, so a metered write would fail + st := state.NewExecutionState( + nil, + state.DefaultParameters(). + WithMeterParameters( + meter.DefaultParameters().WithStorageInteractionLimit( + key1Size+value1Size-1)), + ) + + // same write is not limited inside the disabled block + st.RunWithMeteringDisabled(func() { + err := st.Set(key1, value1) + require.NoError(t, err) + }) + require.Equal(t, uint64(0), st.InteractionUsed()) + + // and is enforced again once metering is re-enabled + err := st.Set(key2, value2) + require.Error(t, err) + }) +} diff --git a/fvm/storage/state/transaction_state.go b/fvm/storage/state/transaction_state.go index 30dbd1828c2..476ca0533f3 100644 --- a/fvm/storage/state/transaction_state.go +++ b/fvm/storage/state/transaction_state.go @@ -49,7 +49,10 @@ type Meter interface { // byte size limit is exceeded MeterEmittedEvent(byteSize uint64) error - // RunWithMeteringDisabled runs f with limits disabled + // RunWithMeteringDisabled runs f with metering disabled. + // While metering is disabled, usage of all metered quantities - computation, + // memory, emitted events, and ledger interaction - is neither accumulated + // toward the totals nor subject to its limit. // This function can be used to run a function that fits one of these cases: // - the function should not fail due to metering limits // - the function is not invokable by the user and has a constant execution time (e.g. fee deduction) From 23054265f864f39094c99733b33f9c29ba638b8d Mon Sep 17 00:00:00 2001 From: Janez Podhostnik Date: Tue, 21 Jul 2026 16:02:56 +0200 Subject: [PATCH 2/3] Add test for unmetered write not populating read cache --- fvm/storage/state/execution_state_test.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/fvm/storage/state/execution_state_test.go b/fvm/storage/state/execution_state_test.go index 6b2f3883c5c..411d004549a 100644 --- a/fvm/storage/state/execution_state_test.go +++ b/fvm/storage/state/execution_state_test.go @@ -311,4 +311,25 @@ func TestExecutionState_MeteringDisabled(t *testing.T) { err := st.Set(key2, value2) require.Error(t, err) }) + + t.Run("write inside disabled scope does not populate the read cache", func(t *testing.T) { + st := state.NewExecutionState( + nil, + state.DefaultParameters(), + ) + + // the write inside the disabled block is not accumulated, and notably + // does not populate the meter's read cache + st.RunWithMeteringDisabled(func() { + require.NoError(t, st.Set(key1, value1)) + }) + require.Equal(t, uint64(0), st.InteractionUsed()) + + // a subsequent metered read of the same key is charged as a fresh + // storage read (in contrast to a metered write, after which the read + // would be a free cache hit) + _, err := st.Get(key1) + require.NoError(t, err) + require.Equal(t, key1Size+value1Size, st.InteractionUsed()) + }) } From 51389057da767b3e4aa7f39679061e4ed5024da9 Mon Sep 17 00:00:00 2001 From: Janez Podhostnik Date: Mon, 3 Aug 2026 16:40:35 +0200 Subject: [PATCH 3/3] Fix stale MeterStorageRead call in test after master merge --- fvm/meter/meter_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fvm/meter/meter_test.go b/fvm/meter/meter_test.go index 417895a1d72..777558831f1 100644 --- a/fvm/meter/meter_test.go +++ b/fvm/meter/meter_test.go @@ -965,7 +965,7 @@ func TestLimitExceededErrorUsedExceedsLimit(t *testing.T) { meter.DefaultParameters().WithStorageInteractionLimit(size - 1), ) - err := m.MeterStorageRead(key, value, true) + err := m.MeterStorageRead(key, value) requireUsedAndLimit(t, err, errors.LimitKindLedgerInteraction, size, size-1) })