Skip to content

Dynamic storage support#982

Merged
Dentosal merged 35 commits into
masterfrom
dento/dynamic-storage
Feb 12, 2026
Merged

Dynamic storage support#982
Dentosal merged 35 commits into
masterfrom
dento/dynamic-storage

Conversation

@Dentosal

@Dentosal Dentosal commented Jan 5, 2026

Copy link
Copy Markdown
Contributor

Implements dynamic storage instructions as described in FuelLabs/fuel-specs#640

The storage initialization in Create transactions is left as-is, i.e. it only allows 32 byte slots. This feature isn't used much, and I don't think it's worth it to continue supporting.

The only breaking change introduced here is adding an immediate offset operand to SRW instruction. Backwards compatibility is retained as the field as previously required to be zeroed.

Checklist

  • Breaking changes are clearly marked as such in the PR description and changelog
  • New behavior is reflected in tests
  • If performance characteristic of an instruction change, update gas costs as well or make a follow-up PR for that
  • The specification matches the implemented behavior (link update PR if changes are needed)

Before requesting review

@Dentosal Dentosal self-assigned this Jan 5, 2026
@Dentosal Dentosal marked this pull request as ready for review January 12, 2026 12:15
@cursor

cursor Bot commented Jan 12, 2026

Copy link
Copy Markdown

PR Summary

High Risk
High risk because it changes FuelVM instruction encoding/semantics (SRW operand change + new storage opcodes), rewires core storage read/write APIs, and updates gas/consensus parameters that affect transaction execution and metering.

Overview
Adds dynamic/variable-length contract storage support by introducing new VM opcodes (SCLR, SRDD/SRDI, SWRD/SWRI, SUPD/SUPI, SPLD, SPCP) plus a storage-preload memory area used by SPLD/SPCP, and updates the interpreter to enforce a new max_storage_slot_length limit.

Makes a backwards-compatible instruction format change to SRW by adding an immediate offset operand, updates legacy storage paths (SRW, SRWQ, range reads) to operate safely over variable-sized slots (including new StorageOutOfBounds panic reason), and bumps gas costs to GasCostsValuesV7 with default costs for the new opcodes.

Reworks fuel-storage’s StorageRead API from read -> bool to read_exact/read_zerofill returning Result<usize, StorageReadError>, and propagates the new typed errors through fuel-vm storage implementations, call/return flow, and tests.

Written by Cursor Bugbot for commit 0fb12cf. This will update automatically on new commits. Configure here.

Comment thread fuel-vm/src/interpreter/memory.rs
Comment thread fuel-vm/src/interpreter/memory.rs
Comment thread fuel-vm/src/interpreter/blockchain.rs Outdated
Comment thread fuel-vm/src/interpreter/blockchain.rs Outdated
self,
interpreter: &mut Interpreter<M, S, Tx, Ecal, V>,
) -> IoResult<ExecuteState, S::DataError> {
let (a, b, c, d) = self.unpack();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It would be nice if we used readable names here like in hte opcode definition

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in eec4b1d

Comment thread fuel-vm/src/interpreter/memory.rs
Comment thread fuel-vm/src/interpreter/memory.rs
Comment thread fuel-vm/src/interpreter/storage.rs Outdated
.storage
.contract_state(&contract_id, &key)
.map_err(RuntimeError::Storage)?
.map(|v| v.as_ref().as_ref().to_vec())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

You can convert the value into owned type (because, under the hood, it is already owned). Overwise you create vector two times(one when we create value, and another here).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Sure. f07f99e

Comment thread fuel-vm/src/interpreter/storage.rs
Comment thread fuel-vm/src/interpreter/storage.rs Outdated
let dst = self.memory.as_mut().write(owner, dst_ptr, len)?;
let value = self
.storage
.contract_state(&contract_id, &key)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Maybe it is better to use read_bytes here? In this case we will avoud creation of the vector on the fuel-core side

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not sure which read_bytes you're talking about?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Sorry, I thought it was the name=D I meant StorageRead::read

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I don't think that works. We need to charge gas based on the full size of the storage slot, and StorageRead::read doesn't expose that info.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Decided on call: lets change the API of StorageRead then

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That required quite a bit of refactoring due to zero filling that was already going against the documentation. Since we need to handle the returned error properly, the trait had to be reworked quite a bit.

37ef2c6

Comment thread fuel-vm/src/interpreter/storage.rs Outdated
@Dentosal Dentosal requested a review from xgreenx January 16, 2026 14:23
let Some(value) = value else {
self.registers[RegId::ERR] = 1;
return Ok(0);
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale data in preload area after failed SPLD

Medium Severity

When storage_preload doesn't find the requested key, the ERR register is set and 0 is returned, but the preload area is not cleared. If a previous successful SPLD loaded data, that stale data remains in the preload area. A subsequent SPCP call that doesn't properly check ERR could copy this stale data, potentially leading to unexpected behavior or information leakage between different storage keys within the same call frame.

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The is the intended behavior from the specification.

srdd: DependentCost::LightOperation {
base: 50,
units_per_gas: 5,
base: 2513,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

why is this so much more expensive than the original SRWQ?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Image

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The values in default_gas_costs.rs aren't related to reality in any way at all. I'll be getting rid of this file in a follow-up PR, see issue #985

The real value from fuel-core that you could compare with is:

        srwq: DependentCost::HeavyOperation {
            base: 520,
            gas_per_unit: 522,
        },

which is still cheaper than this, but not unreasonably so

@MitchTurner MitchTurner left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'd like to understand what the "preload" is doing.

I had Codex walk me through all the tests because they are inscrutable for me to read unfortunately. Seems good to me, but I wish the could be a little more human-friendly. Also, we use rstest on a lot of them in a way that obscures logic, i.e. we use the cases to create logical differences in the test, rather than helping get better coverage. I think of rstest as a manual prop test solution, but we are using it as a DRYing tool, at the cost of clarity.

Comment thread fuel-storage/src/lib.rs
/// Returns `Ok(Ok(length))` if the value does exist, where
/// the `length` is the total length of the value stored at the key.
///
/// Note that inner error `Ok(Err(_))` is used to communicate errors that the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This isn't very intuitive. I'll see how it works later in the code and readdress.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We could instead flatten the error variants into an enum, but that would make it way less ergonomic to call. The idea here is that the outer error you'll always want to propagate using ?, as there's no way you can handle it in any meaningful way at the call site. The inner error, however, you'll always handle using a match statement, because there will be logic to handle missing keys, at least.

pub const DEFAULT: Self = Self::V2(ScriptParametersV2::DEFAULT);

/// Replace the max script length with the given argument
#[cfg(feature = "test-helpers")]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why are these test only now?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Since the new operation with_max_storage_slot_length will panic if used with V1 variant. I don't want to have that kind of thing happen outside tests, and since these are not used anywhere else, it seems prudent to make it test only. The pre-existing functions are marked as test-only for consistency.

fn test_state_read_qword(
input: SRWQInput,
) -> Result<(MemoryInstance, bool), RuntimeError<MemoryStorageError>> {
) -> Result<(MemoryInstance, bool), RuntimeError<Infallible>> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why even keep the Result?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Because some cases return an error. It's just that the storage error type nested inside RuntimeError is now infallible, but there are other error types there too, including RuntimeError::Recoverable which is used here.

#[test_case(false, 1, 29, 32, 0 => Ok((0, 0)); "Wrong contract id")]
#[test_case(false, 0, 29, 33, 0 => Ok((0, 0)); "Wrong key")]
#[test_case(true, 0, None, Word::MAX, 0 => Err(RuntimeError::Recoverable(PanicReason::MemoryOverflow)); "Overflowing key")]
#[test_case(true, 0, None, VM_MAX_RAM, 0 => Err(RuntimeError::Recoverable(PanicReason::MemoryOverflow)); "Overflowing key ram")]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Wait. How can we expect a RuntimeError if it's Infallible?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The Infallible is a generic parameter for StorageError, see the definition of RuntimeError:

pub enum RuntimeError<StorageError> {
    /// Specified error with well-formed fallback strategy, i.e. vm panics.
    Recoverable(PanicReason),
    /// Invalid interpreter state reached unexpectedly, this is a bug
    Bug(Bug),
    /// Storage io error
    Storage(StorageError),
}

Comment thread fuel-vm/src/interpreter/flow.rs Outdated
Comment thread fuel-vm/src/interpreter/storage.rs Outdated
}

/// Fetch a mapping from the contract state.
pub fn contract_state(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This just isn't used anymore?

Comment thread fuel-vm/src/tests/storage.rs Outdated
Comment thread fuel-vm/src/tests/storage.rs Outdated
}

#[test]
fn sww_writes_32_bytes() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This test is breaking my brain. I can't keep track of what thing are slots keys and what things are values while reading it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Rewritten for clarity in 6d9e197

@Dentosal

Dentosal commented Feb 9, 2026

Copy link
Copy Markdown
Contributor Author

I'd like to understand what the "preload" is doing.

The spec says:

The storage preload instruction SPLD loads contents of a storage slot into a staging area. This is a special memory region only readable with SPCP instruction. The staging area is cleared every time a call or return occurs.

This is useful because reading length of a slot requires internally reading the whole slot contents. The primary function of the preload area is to avoid having to load the same slot twice when fetching variable length data. It also allows reading parts of a slot into memory without placing it all continously, which is sometimes useful when i.e. traversing tree structures.

I had Codex walk me through all the tests because they are inscrutable for me to read unfortunately. Seems good to me, but I wish the could be a little more human-friendly.

I attempted abstracting some of the setup code into reusable and well-named functions, but the result was even less readable.

Also, we use rstest on a lot of them in a way that obscures logic, i.e. we use the cases to create logical differences in the test, rather than helping get better coverage. I think of rstest as a manual prop test solution, but we are using it as a DRYing tool, at the cost of clarity.

I'm indeed using it as a DRY tool. I think duplicating the functions would hurt clarity quite a bit more than the rstest does, as now it becomes very hard to see what's the difference between the variants. The code it still by itself rather hard to read, making more of it means you'll have to read twice as much hard-to-understand code. Here rstest-duplication never affects control flow of the fuel-vm instructions.

We could of course do the same thing without rstest and instead move entire test to a separate function, passing in a closure that performs the actual operation-under-test. That would feel rather pointless, and we would lose the nice DX rstest gives us on test failure.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.

Comment thread fuel-vm/src/interpreter/storage.rs Outdated
self.storage
.contract_state_insert(&contract_id, &key, &value)
.map_err(RuntimeError::Storage)?;
Ok(len_after as u64)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SUPD/SUPI gas undercharged for writes to large slots

High Severity

storage_update_from_memory returns len_after (offset + write_len) instead of value.len() (the full slot size). When a small range is updated in a large existing slot, the gas charge is based on the write endpoint, not the total I/O. For example, writing 5 bytes at offset 10 of a 1000-byte slot charges gas for 15 bytes while the storage backend reads and rewrites all 1000 bytes. The return value for gas metering needs to reflect the actual slot size, i.e., value.len(), which equals max(original_len, offset + write_len).

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

MitchTurner
MitchTurner previously approved these changes Feb 9, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.

value.len()
} else {
convert::to_usize(offset).ok_or(PanicReason::MemoryOverflow)?
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Undocumented u64::MAX sentinel enables silent append behavior

Medium Severity

In storage_update_from_memory, offset == u64::MAX is treated as a sentinel meaning "append to end of slot." This implicit behavior is not documented in the SUPD/SUPI opcode descriptions and can be triggered by any contract that sets its offset register to u64::MAX. Instead of getting a StorageOutOfBounds panic (which would naturally occur for such a large offset), the contract silently gets append semantics.

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It's documented in the specification

)?;
interpreter.dependent_gas_charge(
interpreter.gas_costs().srdd().map_err(PanicReason::from)?,
len,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SRDI charges gas using wrong cost accessor name

Low Severity

SRDI charges gas using gas_costs().srdd() and SWRI charges gas using gas_costs().swrd(). While sharing costs between immediate/register variants may be intentional, there are no corresponding srdi or swri fields in GasCostsValuesV7, so these opcodes can never be independently costed. If future gas benchmarking reveals the immediate variants deserve different costs, a new gas cost version will be needed.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is intentional and not a real concern

@Dentosal Dentosal enabled auto-merge February 12, 2026 16:12
@Dentosal Dentosal disabled auto-merge February 12, 2026 17:55
@Dentosal

Copy link
Copy Markdown
Contributor Author

Gas cost comparison from the latest benchmark run versus existing cost of non-dynamic operations. I'm only comparing read operations here, because the database read cost completely dominates the costs; writing is almost free except that we need to read the value anyway to charge for new bytes written. Read costs:

Using srwq: 520 gas per 32 byte slot.

Using srdd: 2593 gas per op, plus 38 bytes for each gas.

Meaning that if you're able to combine at least six slots, dynamic ops are cheaper for reading.

@Dentosal Dentosal added this pull request to the merge queue Feb 12, 2026
Merged via the queue into master with commit ec7761c Feb 12, 2026
45 of 46 checks passed
@Dentosal Dentosal deleted the dento/dynamic-storage branch February 12, 2026 18:21
@github-actions github-actions Bot mentioned this pull request Mar 10, 2026
Dentosal added a commit to FuelLabs/fuel-core that referenced this pull request Mar 31, 2026
## Linked Issues/PRs

FuelLabs/fuel-vm#982

## Description

Adds support and benchmarks for fuel-vm dynamic storage opcodes.

## Checklist
- [x] Breaking changes are clearly marked as such in the PR description
and changelog (None!)
- [x] New behavior is reflected in tests
- [x] [The specification](https://github.com/FuelLabs/fuel-specs/)
matches the implemented behavior (link update PR if changes are needed)

### Before requesting review
- [x] I have reviewed the code myself
- [x] I have created follow-up issues caused by this PR and linked them
here

---------

Co-authored-by: Green Baneling <XgreenX9999@gmail.com>
Dentosal added a commit to FuelLabs/fuel-specs that referenced this pull request Apr 14, 2026
Discussed in #517. VM impl PR:
FuelLabs/fuel-vm#982

Adds a minimal set of opcodes required to operate with dynamic
(variable-sized) storage slots. More instructions can be added later to
speed up common operations, as discussed in #517.

This PR marks sequential bulk read and write opcodes as deprecated.
There's no process for actually removing opcodes, but simply including
DEPRECATED in the heading informs readers that they ought to be careful.
Most imporatantly, the sequential read opcode now panics if attempting
to read slots with size other than 32 bytes. The sequential clear
instuction still works as expected and is kept as-is.

The PR also clarifies how the old storarge instructions interact with
variable sized slots.

### Before requesting review
- [x] I have reviewed the code myself

---------

Co-authored-by: Brandon Kite <brandonkite92@gmail.com>
Co-authored-by: Igor Rončević <ironcev@hotmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants