Skip to content

Commit e810d53

Browse files
committed
Add #[slot(raw = KEY)] to bind a typed storage field to a fixed external slot (e.g. EIP-1967) while preserving the view/mut gate
1 parent cee2581 commit e810d53

4 files changed

Lines changed: 234 additions & 19 deletions

File tree

crates/pvm-contract-macros/src/codegen/abi_gen.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,13 @@ fn storage_layout_helper(slot_fields: &[SlotField]) -> TokenStream {
7878

7979
let layout_emits: Vec<TokenStream> = slot_fields
8080
.iter()
81-
.map(|sf| {
82-
let (slot_expr, offset_expr): (TokenStream, TokenStream) = match sf.slot {
81+
.filter_map(|sf| {
82+
let (slot_expr, offset_expr): (TokenStream, TokenStream) = match &sf.slot {
8383
Slot::Explicit(n) => (quote! { #n }, quote! { 0u8 }),
84+
// Raw external slots (e.g. EIP-1967) live outside solc's
85+
// sequential storage layout — solc doesn't emit them in
86+
// `storageLayout` either, so omit them here.
87+
Slot::ExplicitRaw(_) => return None,
8488
Slot::Auto => {
8589
let const_ident =
8690
quote::format_ident!("{}{}", super::contract::AUTO_SLOT_PREFIX, &sf.name);
@@ -95,12 +99,12 @@ fn storage_layout_helper(slot_fields: &[SlotField]) -> TokenStream {
9599
quote! { "" },
96100
);
97101
let cfgs = &sf.cfg_attrs;
98-
quote! {
102+
Some(quote! {
99103
#(#cfgs)*
100104
{
101105
#emit
102106
}
103-
}
107+
})
104108
})
105109
.collect();
106110

crates/pvm-contract-macros/src/codegen/contract.rs

Lines changed: 96 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use super::dispatch::{
88
MethodInfo, RouteItems, StateMutability, boundary_size_check, generate_param_decoding,
99
generate_revert_encoding_boundary, generate_router,
1010
};
11-
use super::storage_layout::extract_optional_slot_attr;
11+
use super::storage_layout::{SlotAttr, extract_optional_slot_attr};
1212
use crate::signature::{SolType, compute_selector};
1313
use crate::utils::{compute_function_signature, to_snake_case};
1414

@@ -208,6 +208,13 @@ pub(super) enum Slot {
208208
/// (`PACKED_BYTES == 32`); sub-word types must use auto-numbering.
209209
/// See [`SlotField`] for the rationale.
210210
Explicit(u64),
211+
/// Explicit `#[slot(raw = EXPR)]`: bind the field to a fixed,
212+
/// externally-known 32-byte slot (e.g. an EIP-1967 proxy slot), outside the
213+
/// compiler-assigned sequential range. Unlike numeric `#[slot(N)]` this
214+
/// accepts sub-word types and places them right-aligned (`offset =
215+
/// 32 - PACKED_BYTES`) to match solc, since a pseudo-random external slot
216+
/// has no sibling fields to pack with.
217+
ExplicitRaw(syn::Expr),
211218
/// Auto-numbered: position among auto-numbered fields is taken from
212219
/// declaration order during the slot-chain build. Packs sub-word
213220
/// siblings via `layout_step`.
@@ -262,11 +269,14 @@ fn auto_chain_fields(slot_fields: &[SlotField]) -> Vec<super::storage_layout::Ch
262269
}
263270

264271
impl SlotField {
265-
/// Explicit slot value, or `None` if auto-numbered.
272+
/// Explicit *numeric* slot value, or `None` if auto-numbered or bound to a
273+
/// raw external slot. Raw slots return `None` because they live outside the
274+
/// compiler-assigned sequential range and don't participate in the
275+
/// numeric overlap / full-slot-only checks.
266276
pub(super) fn explicit_slot(&self) -> Option<u64> {
267277
match self.slot {
268278
Slot::Explicit(n) => Some(n),
269-
Slot::Auto => None,
279+
Slot::ExplicitRaw(_) | Slot::Auto => None,
270280
}
271281
}
272282
}
@@ -1298,19 +1308,35 @@ pub fn expand_contract(args: ContractArgs, input: ItemMod) -> syn::Result<TokenS
12981308
let name = &sf.name;
12991309
let ty = &sf.ty;
13001310
let cfgs = &sf.cfg_attrs;
1301-
let (slot_expr, offset_expr, alone_expr): (TokenStream, TokenStream, TokenStream) =
1302-
match sf.slot {
1311+
let (key_expr, offset_expr, alone_expr): (TokenStream, TokenStream, TokenStream) =
1312+
match &sf.slot {
13031313
// Explicit `#[slot(N)]` is restricted to full-slot types
13041314
// elsewhere in this file (`explicit_slot_full_slot_only_checks`).
13051315
// Full-slot components ignore `alone`, so the literal `true`
13061316
// is purely cosmetic — it would behave identically as `false`
13071317
// for `Mapping`/`Lazy<U256>`/etc.
1308-
Slot::Explicit(n) => (quote! { #n }, quote! { 0u8 }, quote! { true }),
1318+
Slot::Explicit(n) => (
1319+
quote! { ::pvm_contract_sdk::StorageKey::from_slot(#n) },
1320+
quote! { 0u8 },
1321+
quote! { true },
1322+
),
1323+
// `#[slot(raw = EXPR)]` binds the field to a fixed external
1324+
// 32-byte slot. Sub-word types are placed right-aligned at
1325+
// `32 - PACKED_BYTES` (matching solc), and `alone = true`
1326+
// because a pseudo-random external slot has no sibling
1327+
// sub-word fields to preserve on write.
1328+
Slot::ExplicitRaw(expr) => (
1329+
quote! { ::pvm_contract_sdk::StorageKey::from_raw(#expr) },
1330+
quote! {
1331+
(32 - <#ty as ::pvm_contract_sdk::StorageComponent>::PACKED_BYTES) as u8
1332+
},
1333+
quote! { true },
1334+
),
13091335
Slot::Auto => {
13101336
let const_ident = quote::format_ident!("{}{}", AUTO_SLOT_PREFIX, name);
13111337
let alone_ident = quote::format_ident!("{}{}", AUTO_ALONE_PREFIX, name);
13121338
(
1313-
quote! { #const_ident.slot },
1339+
quote! { ::pvm_contract_sdk::StorageKey::from_slot(#const_ident.slot) },
13141340
quote! { #const_ident.offset },
13151341
quote! { #alone_ident },
13161342
)
@@ -1319,7 +1345,7 @@ pub fn expand_contract(args: ContractArgs, input: ItemMod) -> syn::Result<TokenS
13191345
quote! {
13201346
#(#cfgs)*
13211347
#name: <#ty as ::pvm_contract_sdk::StorageComponent>::new_at(
1322-
::pvm_contract_sdk::StorageKey::from_slot(#slot_expr),
1348+
#key_expr,
13231349
#offset_expr,
13241350
#alone_expr,
13251351
host.clone(),
@@ -1853,7 +1879,7 @@ fn extract_slot_fields_from_struct(item_struct: &syn::ItemStruct) -> syn::Result
18531879
struct Raw {
18541880
name: Ident,
18551881
ty: syn::Type,
1856-
explicit: Option<u64>,
1882+
explicit: Option<SlotAttr>,
18571883
cfg_attrs: Vec<syn::Attribute>,
18581884
original_field: syn::Field,
18591885
}
@@ -1921,8 +1947,14 @@ fn extract_slot_fields_from_struct(item_struct: &syn::ItemStruct) -> syn::Result
19211947

19221948
let mut fields = Vec::new();
19231949
for raw in raws {
1924-
let slot = if let Some(n) = raw.explicit {
1925-
Slot::Explicit(n)
1950+
let slot = if let Some(attr) = raw.explicit {
1951+
match attr {
1952+
SlotAttr::Numeric(n) => Slot::Explicit(n),
1953+
// Raw external slots carry an unevaluated `[u8; 32]` expression;
1954+
// like numeric explicit slots they may carry `#[cfg]` (they don't
1955+
// shift any later field's slot), so no cfg gate here.
1956+
SlotAttr::Raw(expr) => Slot::ExplicitRaw(expr),
1957+
}
19261958
} else {
19271959
// Auto-numbered fields share a const chain across slot consts;
19281960
// a #[cfg]-disabled field in the middle of the chain would break
@@ -3262,6 +3294,59 @@ mod tests {
32623294
);
32633295
}
32643296

3297+
#[test]
3298+
fn raw_slot_field_constructs_from_raw_key_right_aligned() {
3299+
let item: ItemMod = syn::parse_str(
3300+
r#"
3301+
mod my_proxy {
3302+
const IMPL_SLOT: [u8; 32] = [0u8; 32];
3303+
pub struct MyProxy {
3304+
#[slot(raw = IMPL_SLOT)]
3305+
impl_addr: Lazy<Address>,
3306+
}
3307+
impl MyProxy {
3308+
#[pvm_contract_macros::constructor]
3309+
pub fn new(&mut self) {}
3310+
3311+
#[pvm_contract_macros::method]
3312+
pub fn implementation(&self) -> Address {
3313+
self.impl_addr.get()
3314+
}
3315+
}
3316+
}
3317+
"#,
3318+
)
3319+
.unwrap();
3320+
3321+
let output = expand_contract(ContractArgs::default(), item)
3322+
.unwrap()
3323+
.to_string();
3324+
3325+
// Raw-slot fields bind to StorageKey::from_raw(EXPR), not from_slot.
3326+
assert!(
3327+
output.contains(":: StorageKey :: from_raw (IMPL_SLOT)"),
3328+
"raw slot field should pass StorageKey::from_raw(IMPL_SLOT).\n\
3329+
Expanded output:\n{output}"
3330+
);
3331+
assert!(
3332+
!output.contains("from_slot"),
3333+
"a raw-only contract should emit no from_slot calls.\n\
3334+
Expanded output:\n{output}"
3335+
);
3336+
// Sub-word types are placed right-aligned at 32 - PACKED_BYTES.
3337+
assert!(
3338+
output.contains("(32 - < Lazy < Address > as :: pvm_contract_sdk :: StorageComponent > :: PACKED_BYTES) as u8"),
3339+
"raw sub-word field should be right-aligned at 32 - PACKED_BYTES.\n\
3340+
Expanded output:\n{output}"
3341+
);
3342+
// The #[slot(raw = ...)] attribute must be stripped from the struct.
3343+
assert!(
3344+
!output.contains("# [slot"),
3345+
"Slot attributes should be stripped from the struct output.\n\
3346+
Expanded output:\n{output}"
3347+
);
3348+
}
3349+
32653350
#[test]
32663351
fn slot_fields_initialize_in_deploy_and_call() {
32673352
let item: ItemMod = syn::parse_str(

crates/pvm-contract-macros/src/codegen/storage_layout.rs

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -181,9 +181,21 @@ pub(super) fn generate_layout_emit(
181181
}
182182
}
183183

184-
/// Extract the `#[slot(N)]` attribute value from a field, if present.
184+
/// A parsed `#[slot(...)]` attribute value.
185+
#[derive(Debug, Clone)]
186+
pub(super) enum SlotAttr {
187+
/// `#[slot(N)]` — a numeric slot index in the compiler-assigned range.
188+
Numeric(u64),
189+
/// `#[slot(raw = EXPR)]` — bind the field to a fixed, externally-known
190+
/// 32-byte slot (e.g. an EIP-1967 proxy slot). `EXPR` must evaluate to a
191+
/// `[u8; 32]`; it is quoted verbatim into the field initializer.
192+
Raw(syn::Expr),
193+
}
194+
195+
/// Extract the `#[slot(...)]` attribute value from a field, if present.
196+
/// Accepts either `#[slot(N)]` (numeric) or `#[slot(raw = EXPR)]`.
185197
/// Returns `None` when the field has no `#[slot]` attribute.
186-
pub(super) fn extract_optional_slot_attr(field: &syn::Field) -> syn::Result<Option<u64>> {
198+
pub(super) fn extract_optional_slot_attr(field: &syn::Field) -> syn::Result<Option<SlotAttr>> {
187199
let mut found: Option<&syn::Attribute> = None;
188200
for attr in &field.attrs {
189201
if attr.path().is_ident("slot") {
@@ -199,6 +211,21 @@ pub(super) fn extract_optional_slot_attr(field: &syn::Field) -> syn::Result<Opti
199211
let Some(attr) = found else {
200212
return Ok(None);
201213
};
202-
let slot: syn::LitInt = attr.parse_args()?;
203-
Ok(Some(slot.base10_parse::<u64>()?))
214+
let parsed = attr.parse_args_with(|input: syn::parse::ParseStream| {
215+
if input.peek(syn::Ident) {
216+
let ident: syn::Ident = input.parse()?;
217+
if ident != "raw" {
218+
return Err(syn::Error::new_spanned(
219+
&ident,
220+
"expected an integer slot literal or `raw = <[u8; 32] expr>`",
221+
));
222+
}
223+
input.parse::<syn::Token![=]>()?;
224+
Ok(SlotAttr::Raw(input.parse::<syn::Expr>()?))
225+
} else {
226+
let slot: syn::LitInt = input.parse()?;
227+
Ok(SlotAttr::Numeric(slot.base10_parse::<u64>()?))
228+
}
229+
})?;
230+
Ok(Some(parsed))
204231
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
#![cfg(not(feature = "abi-gen"))]
2+
//! Worked example: a minimal EIP-1967 upgradeable proxy.
3+
//!
4+
//! The implementation address lives at the standard pseudo-random slot
5+
//! `keccak256("eip1967.proxy.implementation") - 1`, deliberately outside the
6+
//! compiler-assigned sequential slot range so it never collides with regular
7+
//! fields. It's bound to a typed `Lazy<Address>` field via `#[slot(raw = …)]`,
8+
//! so reads/writes go through the borrow-checker's view-vs-mutating gate just
9+
//! like any other storage field — no raw `host.get_storage`, no unsafe.
10+
//!
11+
//! The EIP-1967 constant lives here in the contract, not the SDK: the SDK only
12+
//! provides the generic `#[slot(raw = KEY)]` mechanism.
13+
14+
use pvm_contract_sdk::{
15+
Address, Host, Lazy, MockHost, MockHostBuilder, StorageComponent, StorageKey,
16+
};
17+
18+
/// `bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1)`.
19+
const IMPLEMENTATION_SLOT: [u8; 32] = [
20+
0x36, 0x08, 0x94, 0xa1, 0x3b, 0xa1, 0xa3, 0x21, 0x06, 0x67, 0xc8, 0x28, 0x49, 0x2d, 0xb9, 0x8d,
21+
0xca, 0x3e, 0x20, 0x76, 0xcc, 0x37, 0x35, 0xa9, 0x20, 0xa3, 0xca, 0x50, 0x5d, 0x38, 0x2b, 0xbc,
22+
];
23+
24+
#[allow(dead_code)]
25+
#[pvm_contract_sdk::contract]
26+
mod upgradeable_proxy {
27+
use super::*;
28+
29+
pub struct UpgradeableProxy {
30+
#[slot(raw = IMPLEMENTATION_SLOT)]
31+
pub impl_addr: Lazy<Address>,
32+
}
33+
34+
impl UpgradeableProxy {
35+
#[pvm_contract_sdk::constructor]
36+
pub fn new(&mut self) {}
37+
38+
/// Current implementation address, read from the EIP-1967 slot.
39+
#[pvm_contract_sdk::method]
40+
pub fn implementation(&self) -> Address {
41+
self.impl_addr.get()
42+
}
43+
44+
/// Point the proxy at a new implementation contract.
45+
#[pvm_contract_sdk::method]
46+
pub fn upgrade_to(&mut self, new_implementation: Address) {
47+
self.impl_addr.set(&new_implementation);
48+
}
49+
}
50+
}
51+
52+
use upgradeable_proxy::UpgradeableProxy;
53+
54+
/// Build an instance the same way the macro's on-chain path would: bind the
55+
/// `Lazy<Address>` field to the raw EIP-1967 slot, right-aligned like solc.
56+
fn proxy() -> (UpgradeableProxy, MockHost) {
57+
let mock = MockHostBuilder::new().build();
58+
let host = Host::from_dyn(::std::rc::Rc::new(mock.clone()));
59+
let contract = UpgradeableProxy {
60+
impl_addr: <Lazy<Address> as StorageComponent>::new_at(
61+
StorageKey::from_raw(IMPLEMENTATION_SLOT),
62+
(32 - <Lazy<Address> as StorageComponent>::PACKED_BYTES) as u8,
63+
true,
64+
host.clone(),
65+
),
66+
host,
67+
};
68+
(contract, mock)
69+
}
70+
71+
#[test]
72+
fn implementation_defaults_to_zero() {
73+
let (p, _) = proxy();
74+
assert_eq!(p.implementation(), Address::from([0u8; 20]));
75+
}
76+
77+
#[test]
78+
fn upgrade_to_then_implementation_roundtrips() {
79+
let (mut p, _) = proxy();
80+
let impl_addr = Address::from([0x42; 20]);
81+
p.upgrade_to(impl_addr);
82+
assert_eq!(p.implementation(), impl_addr);
83+
}
84+
85+
#[test]
86+
fn upgrade_to_writes_the_eip1967_slot_right_aligned() {
87+
let (mut p, mock) = proxy();
88+
let impl_addr = Address::from([0xCD; 20]);
89+
p.upgrade_to(impl_addr);
90+
91+
// Confirm the write landed at the EIP-1967 slot, right-aligned like solc
92+
// (low 20 bytes), so a slot written by a real Solidity proxy would decode.
93+
let raw = mock
94+
.get_raw_storage(&IMPLEMENTATION_SLOT)
95+
.expect("implementation slot was written");
96+
let mut expected = [0u8; 32];
97+
expected[12..].copy_from_slice(impl_addr.as_ref() as &[u8; 20]);
98+
assert_eq!(raw.as_slice(), &expected);
99+
}

0 commit comments

Comments
 (0)