How Sway Storage Allocation and Storage Keys Work: Compile-Time Key Generation in Fuel VM Contracts
Sway computes deterministic 32-byte storage keys at compile time by hashing human-readable paths that encode namespace hierarchies and field names, then represents these keys in the compiler IR to generate Fuel VM storage instructions.
In the FuelLabs/sway repository, contract data persistence relies on a sophisticated compile-time key generation system that maps logical storage declarations to specific slots in the Fuel VM's persistent key-value store. Understanding how Sway storage allocation and storage keys work is essential for developers optimizing contract storage layout and predicting state access patterns.
How Sway Generates Storage Keys
The compiler derives storage keys through a deterministic three-step pipeline that transforms human-readable declarations into cryptographically secure 32-byte identifiers. This process occurs in sway-core/src/ir_generation/storage.rs and sway-ir/src/storage_key.rs.
Step 1: Constructing the Storage Path
The process begins with get_storage_key_string, which constructs a canonical string encoding the top-level storage namespace, any nested namespaces, and the variable name. For top-level variables, the function concatenates STORAGE_TOP_LEVEL_NAMESPACE, STORAGE_FIELD_SEPARATOR, and the identifier. For nested namespaces, it inserts STORAGE_NAMESPACE_SEPARATOR between hierarchical components:
// sway-core/src/ir_generation/storage.rs
pub fn get_storage_key_string(storage_field_names: &[String]) -> String {
if storage_field_names.len() == 1 {
format!(
"{}{}{}",
sway_utils::constants::STORAGE_TOP_LEVEL_NAMESPACE,
sway_utils::constants::STORAGE_FIELD_SEPARATOR,
storage_field_names.last().unwrap(),
)
} else {
format!(
"{}{}{}{}{}",
sway_utils::constants::STORAGE_TOP_LEVEL_NAMESPACE,
sway_utils::constants::STORAGE_NAMESPACE_SEPARATOR,
storage_field_names
.iter()
.take(storage_field_names.len() - 1)
.cloned()
.collect::<Vec<_>>()
.join(sway_utils::constants::STORAGE_NAMESPACE_SEPARATOR),
sway_utils::constants::STORAGE_FIELD_SEPARATOR,
storage_field_names.last().unwrap(),
)
}
}
Step 2: Hashing with Domain Separation
The hash_storage_key_string function prefixes the path with a domain byte (0u8) to separate compiler-generated keys from user-supplied keys (such as those used by StorageMap), then applies SHA-256 hashing:
// sway-core/src/ir_generation/storage.rs
fn hash_storage_key_string(storage_key_string: &str) -> Bytes32 {
let mut hasher = Hasher::default();
hasher.input(sway_utils::constants::STORAGE_DOMAIN);
hasher.input(storage_key_string);
hasher.finalize()
}
The public helper get_storage_key determines whether to use this hashed value or an explicit user-provided key:
// sway-core/src/ir_generation/storage.rs
pub(super) fn get_storage_key(storage_field_names: Vec<String>, key: Option<U256>) -> Bytes32 {
match key {
Some(key) => key.to_be_bytes().into(),
None => hash_storage_key_string(&get_storage_key_string(&storage_field_names)),
}
}
Step 3: IR Representation as StorageKey
In sway-ir/src/storage_key.rs, the compiler creates a StorageKey value within the IR's Context. This struct wraps a constant of type { b256, u64, b256 } containing the slot hash, word offset within the slot, and an optional field identifier for struct members:
// sway-ir/src/storage_key.rs
pub struct StorageKey(#[in_context(storage_keys)] pub slotmap::DefaultKey);
impl StorageKey {
pub fn new(context: &mut Context, slot: [u8; 32], offset: u64, field_id: [u8; 32]) -> Self {
let b256_ty = Type::get_b256(context);
let uint64_ty = Type::get_uint64(context);
let key_ty = Type::new_struct(context, vec![b256_ty, uint64_ty, b256_ty]);
let ptr_ty = Type::new_typed_pointer(context, key_ty);
let slot = ConstantContent::new_b256(context, slot);
let offset = ConstantContent::new_uint(context, 64, offset);
let field_id = ConstantContent::new_b256(context, field_id);
let key = ConstantContent::new_struct(
context,
vec![b256_ty, uint64_ty, b256_ty],
vec![slot, offset, field_id],
);
let key = Constant::unique(context, key);
StorageKey(context.storage_keys.insert(StorageKeyContent { ptr_ty, key }))
}
}
Storage Allocation in the Compiler IR
Once keys are generated, the compiler performs allocation steps to map logical storage accesses to Fuel VM instructions, primarily in sway-core/src/ir_generation/function.rs.
Determining Slot and Offset
For top-level variables, the slot equals the hashed key directly. When accessing struct fields, the compiler calculates word offsets by dividing byte offsets by 8 (the Fuel VM word size), then adds this to the base slot using add_to_b256. The field_id parameter in StorageKey::new contains the hash of the struct-field path for member access, or zero for top-level variables.
Emitting Storage Instructions
The compiler creates a StorageKey instance via StorageKey::new(context, slot, offset, field_id) and emits a GetStorageKey IR instruction. This instruction is later lowered to Fuel VM state_read_* or state_write_* intrinsics. For constant initialization, serialize_to_storage_slots in sway-core/src/ir_generation/storage.rs walks constant values, computes final keys via add_to_b256 for each word, and emits StorageSlot structures consumed by the VM.
Practical Example: Computing Storage Keys
Consider this Sway contract demonstrating namespace hierarchies and the generated paths:
// storage_decls.sw
storage {
// Top-level variable `counter` → key = hash("storage.counter")
counter: u64 = 0,
// Nested namespace `my_ns::value`
// → key = hash("storage::my_ns.value")
my_ns {
value: u8 = 42
}
}
The compiler generates these keys deterministically using the internal helpers:
use sway_core::ir_generation::storage::{get_storage_key_string, get_storage_key};
fn main() {
// 1️⃣ Counter (top-level)
let counter_path = vec!["counter".into()];
let counter_key = get_storage_key(counter_path.clone(), None);
println!("counter key: 0x{:x}", Bytes32::from(counter_key));
// 2️⃣ my_ns.value (namespaced)
let ns_path = vec!["my_ns".into(), "value".into()];
let ns_key = get_storage_key(ns_path.clone(), None);
println!("my_ns.value key: 0x{:x}", Bytes32::from(ns_key));
// 3️⃣ Offset example – field `inner` inside a struct stored at `my_struct`
// Suppose `my_struct` occupies slot S, and `inner` is 8 bytes after the start.
// Offset in words = 8 / 8 = 1
let base_key = get_storage_key(vec!["my_struct".into()], None);
let offset_key = add_to_b256(base_key, 1);
println!("my_struct.inner key (slot+1): 0x{:x}", Bytes32::from(offset_key));
}
Summary
- Deterministic hashing ensures identical storage declarations always produce identical 32-byte keys across compilations, guaranteeing reproducible contract deployments.
- Domain separation via a leading
0u8byte prevents collisions between compiler-generated keys and user-supplied map keys inStorageMap. - Path encoding in
get_storage_key_stringcombines namespaces, variable names, and field names into human-readable strings before SHA-256 hashing. - IR representation uses the
StorageKeystruct insway-ir/src/storage_key.rsto carry both type information and constant values, enabling storage-aware optimizations. - Offset calculation addresses struct fields by adding word-based offsets to base slots using
add_to_b256insway-core/src/ir_generation/function.rs.
Frequently Asked Questions
How does Sway prevent storage key collisions between compiler-generated and user-supplied keys?
The compiler implements domain separation by prefixing storage path strings with a domain byte (0u8) in hash_storage_key_string before hashing. This distinguishes compiler-generated keys (used for static storage variables) from user-supplied keys (used by dynamic collections like StorageMap), ensuring they occupy distinct key spaces even if path strings coincide.
Can developers predict their contract's storage keys before deployment?
Yes. Since Sway uses deterministic SHA-256 hashing of canonical path strings in sway-core/src/ir_generation/storage.rs, developers can compute expected keys by replicating the get_storage_key_string and hash_storage_key_string logic. The same source code always generates the same 32-byte identifiers, allowing offline key prediction for integration testing.
How does Sway calculate storage slots for struct fields?
The compiler calculates byte offsets based on field layout within the struct, divides by 8 to convert to word offsets (the Fuel VM's native addressing unit), and adds these to the base slot using add_to_b256 in sway-core/src/ir_generation/function.rs. This allows efficient packing of multiple fields within the 32-byte storage slot granularity.
What is the relationship between the IR StorageKey and the final VM storage operations?
The IR StorageKey (defined in sway-ir/src/storage_key.rs) is a compile-time constant struct containing the slot hash, word offset, and field identifier. During code generation, the compiler translates GetStorageKey instructions containing these values into Fuel VM state_read_* and state_write_* intrinsics that operate on the raw 32-byte storage addresses.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →