Skip to content

Commit 201decf

Browse files
committed
feat!: update private_mutable replace functions to closure style (#7065)
# Motivation Previously, updating a note required reading it first via `get_note` (which nullified and recreated it) and then calling `replace` — effectively proving the note twice. Now, `replace` accepts a callback that transforms the current note directly, and `initialize_or_replace` uses this updated `replace` internally. This reduces circuit cost while maintaining exactly one current note. # Key points 1. `replace(self, new_note)` (old) → `replace(self, f)` (new), where `f` takes the current note and returns a transformed note. 2. `initialize_or_replace(self, note)` (old) → `initialize_or_replace(self, init_note, f)` (new). Uninitialized variables use `init_note`, initialized ones pass the transform function to `replace`. 3. The previous note is automatically nullified before the new note is inserted. 4. `NoteEmission<Note>` still requires `.emit()` or `.discard()`. 5. Example contracts and docs have been updated to reflect this new usage pattern. # Breaking change Code relying on the old `get_note() + replace()` pattern may need to be updated. # Addresses Closes #7065
1 parent 8b273b4 commit 201decf

File tree

10 files changed

+202
-107
lines changed

10 files changed

+202
-107
lines changed

boxes/boxes/react/src/contracts/src/main.nr

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,19 @@ contract BoxReact {
3131
#[private]
3232
fn setNumber(number: Field, owner: AztecAddress) {
3333
let numbers = storage.numbers;
34-
let new_number = ValueNote::new(number, owner);
3534

36-
numbers.at(owner).replace(new_number).emit(
37-
&mut context,
38-
owner,
39-
MessageDelivery.CONSTRAINED_ONCHAIN,
40-
);
35+
numbers.at(owner)
36+
.replace(|_old| {
37+
ValueNote::new(number, owner)
38+
})
39+
.emit(
40+
&mut context,
41+
owner,
42+
MessageDelivery.CONSTRAINED_ONCHAIN,
43+
);
4144
}
4245

46+
4347
#[utility]
4448
unconstrained fn getNumber(owner: AztecAddress) -> ValueNote {
4549
let numbers = storage.numbers;

boxes/boxes/vite/src/contracts/src/main.nr

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,12 @@ contract BoxReact {
3131
#[private]
3232
fn setNumber(number: Field, owner: AztecAddress) {
3333
let numbers = storage.numbers;
34-
let mut new_number = ValueNote::new(number, owner);
3534

36-
numbers.at(owner).replace(new_number).emit(
35+
36+
numbers.at(owner)
37+
.replace(|_old| {
38+
ValueNote::new(number, owner)
39+
}).emit(
3740
&mut context,
3841
owner,
3942
MessageDelivery.CONSTRAINED_ONCHAIN,

docs/docs/developers/guides/smart_contracts/storage_types.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,14 @@ An unconstrained method to check whether the PrivateMutable has been initialized
105105

106106
#### `replace`
107107

108-
To update the value of a `PrivateMutable`, we can use the `replace` method. The method takes a new note as input and replaces the current note with the new one. It emits a nullifier for the old value, and inserts the new note into the data tree.
108+
To update the value of a `PrivateMutable`, we can use the `replace` method. The method takes a function (or closure) that transforms the current note into a new one.
109109

110-
An example of this is seen in a example card game, where we create a new note (a `CardNote`) containing some new data, and replace the current note with it:
110+
When called, the method will:
111+
- Nullify the old note
112+
- Apply the transform function to produce a new note
113+
- Insert the new note into the data tree
114+
115+
An example of this is seen in an example card game, where an update function is passed in to transform the current note into a new one (in this example, updating a `CardNote` data):
111116

112117
#include_code state_vars-PrivateMutableReplace /noir-projects/noir-contracts/contracts/docs/docs_example_contract/src/main.nr rust
113118

docs/docs/migration_notes.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,36 @@ As we prepare for a bigger `Wallet` interface refactor and the upcoming `WalletS
4545

4646
## [Aztec.nr]
4747

48+
49+
### PrivateMutable: replace / initialize_or_replace behaviour change
50+
51+
**Motivation:**
52+
Updating a note used to require reading it first (via `get_note`, which nullifies and recreates it) and then calling `replace` — effectively proving a note twice. Now, `replace` accepts a callback that transforms the current note directly, and `initialize_or_replace` simply uses this updated `replace` internally. This reduces circuit cost while maintaining exactly one current note.
53+
54+
**Key points:**
55+
56+
1. `replace(self, new_note)` (old) → `replace(self, f)` (new), where `f` takes the current note and returns a transformed note.
57+
2. `initialize_or_replace(self, note)` (old) → `initialize_or_replace(self, init_note, f)` (new). Uninitialized variables use `init_note`, initialized ones pass the transform function to `replace`.
58+
3. Previous note is automatically nullified before the new note is inserted.
59+
4. `NoteEmission<Note>` still requires `.emit()` or `.discard()`.
60+
61+
**Example Migration:**
62+
63+
```diff
64+
- storage.my_var.replace(new_note);
65+
+ let x: Field = 5;
66+
+ storage.my_var.replace(|note| UintNote::new(note.value + x));
67+
```
68+
69+
```diff
70+
- storage.my_var.initialize_or_replace(init_note);
71+
+ storage.my_var.initialize_or_replace(init_note, |note| UintNote::new(note.value + 5));
72+
```
73+
74+
75+
- The callback can be a closure (inline) or a named function.
76+
- Any previous assumptions that replace simply inserts a new_note directly must be updated.
77+
4878
### Unified oracles into single get_utility_context oracle
4979

5080
The following oracles:

noir-projects/aztec-nr/aztec/src/state_vars/private_mutable.nr

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -251,11 +251,12 @@ where
251251
}
252252
// docs:end:initialize
253253

254-
/// Replaces the current note of a PrivateMutable state variable with a new note.
254+
/// Reads the current note of a PrivateMutable state variable, nullifies it,
255+
/// and inserts a new note produced by a user-provided function.
255256
///
256-
/// This function implements the typical "nullify-and-create" pattern for updating
257-
/// private state in Aztec. It first retrieves the current note, nullifies it
258-
/// (marking it as "spent"), and then inserts a `new_note` with the updated data.
257+
/// This function implements a "read-and-replace" pattern for updating private state in Aztec.
258+
/// It first retrieves the current note, then nullifies it (marking it as spent),
259+
/// and finally inserts a `new_note` produced by the user-provided function `f`.
259260
///
260261
/// This is conceptually similar to updating a variable in Ethereum smart contracts,
261262
/// except that in Aztec we achieve this by consuming the old note and creating a
@@ -267,8 +268,8 @@ where
267268
///
268269
/// ## Arguments
269270
///
270-
/// * `new_note` - The new note that will replace the current note. This becomes
271-
/// the new "current value" of the PrivateMutable.
271+
/// * `f` - A function that takes the current `Note` and returns a new `Note`.
272+
/// This allows you to transform the current note before it is reinserted.
272273
///
273274
/// ## Returns
274275
///
@@ -284,15 +285,16 @@ where
284285
/// - Retrieves the current note from the PXE via an oracle call
285286
/// - Validates that the current note exists and belongs to this storage slot
286287
/// - Computes the nullifier for the current note and pushes it to the context
287-
/// - Inserts the provided `new_note` into the Note Hash Tree
288+
/// - Calls the user-provided function `f` to produce a new note
289+
/// - Inserts the resulting `new_note` into the Note Hash Tree using 'create_note'
288290
/// - Returns a NoteEmission type for the `new_note`, that allows the caller to
289291
/// decide how to encrypt and deliver this note to its intended recipient.
290292
///
291293
/// The nullification of the previous note ensures that it cannot be used again,
292294
/// maintaining the invariant that a PrivateMutable has exactly one current note.
293295
///
294296
// docs:start:replace
295-
pub fn replace(self, new_note: Note) -> NoteEmission<Note>
297+
pub fn replace<Env>(self, f: fn[Env](Note) -> Note) -> NoteEmission<Note>
296298
where
297299
Note: Packable,
298300
{
@@ -306,23 +308,23 @@ where
306308
note_hash_for_read_request,
307309
);
308310

311+
let new_note = f(prev_retrieved_note.note);
312+
309313
// Add replacement note.
310314
create_note(self.context, self.storage_slot, new_note)
311315
}
312316
// docs:end:replace
313317

314-
/// Initializes the PrivateMutable if it's uninitialized, or replaces the current
315-
/// note if it's already initialized.
318+
/// Initializes the PrivateMutable if it's uninitialized, or replaces the current note
319+
/// using a transform function.
316320
///
317-
/// This is a convenience function that automatically chooses between `initialize`
318-
/// and `replace` based on whether the PrivateMutable has been previously initialized.
319-
/// This is useful when you don't know the initialization state beforehand, such
320-
/// as in functions that may be called multiple times.
321+
/// If uninitialized, `init_note` is used to initialize. If already initialized, the `transform_fn`
322+
/// is passed to `replace`, which retrieves the current note, nullifies it, and inserts the transformed note.
321323
///
322324
/// ## Arguments
323325
///
324-
/// * `note` - The note to store. If initializing, this becomes the first note.
325-
/// If replacing, this becomes the new current note.
326+
/// * `init_note` - The note to use if the PrivateMutable is uninitialized.
327+
/// * `transform_fn` - A function that takes the current `Note` and returns a new `Note` to replace it.
326328
///
327329
/// ## Returns
328330
///
@@ -332,7 +334,11 @@ where
332334
/// the note, or `.discard()` to skip emission.
333335
/// See NoteEmission documentation for more details.
334336
///
335-
pub fn initialize_or_replace(self, note: Note) -> NoteEmission<Note>
337+
pub fn initialize_or_replace<Env>(
338+
self,
339+
init_note: Note,
340+
transform_fn: fn[Env](Note) -> Note,
341+
) -> NoteEmission<Note>
336342
where
337343
Note: Packable,
338344
{
@@ -350,10 +356,10 @@ where
350356
let is_initialized =
351357
unsafe { check_nullifier_exists(self.compute_initialization_nullifier()) };
352358

353-
if (!is_initialized) {
354-
self.initialize(note)
359+
if !is_initialized {
360+
self.initialize(init_note)
355361
} else {
356-
self.replace(note)
362+
self.replace(transform_fn)
357363
}
358364
}
359365

noir-projects/aztec-nr/aztec/src/state_vars/private_mutable/test.nr

Lines changed: 78 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,63 @@ unconstrained fn replace_uninitialized() {
2727
env.private_context(|context| {
2828
let state_var = in_private(context);
2929

30-
let note = MockNote::new(VALUE).build_note();
31-
let _ = state_var.replace(note);
30+
let _ = state_var.replace(|_old_note| {
31+
let note = MockNote::new(VALUE).build_note();
32+
note
33+
});
34+
});
35+
}
36+
37+
// Named function to use as a callback for replace
38+
fn plus_one(note: MockNote) -> MockNote {
39+
MockNote::new(note.value + 1).build_note()
40+
}
41+
42+
#[test]
43+
unconstrained fn test_replace_plus_one() {
44+
let env = TestEnvironment::new();
45+
46+
env.private_context(|context| {
47+
let mut state_var = in_private(context);
48+
49+
// Initialize with a known value
50+
let INIT_VALUE: Field = 7;
51+
let EXPECTED_VALUE: Field = 8;
52+
let initial_note = MockNote::new(INIT_VALUE).build_note();
53+
let _ = state_var.initialize(initial_note);
54+
55+
// run read_and_replace with helper function
56+
let emission = state_var.replace(plus_one);
57+
58+
// emission should contain new note with value 8
59+
let expected_note = MockNote::new(EXPECTED_VALUE).build_note();
60+
assert_eq(emission.note, expected_note);
61+
});
62+
}
63+
64+
#[test]
65+
unconstrained fn test_replace_capture_variable() {
66+
let env = TestEnvironment::new();
67+
68+
env.private_context(|context| {
69+
let mut state_var = in_private(context);
70+
71+
// Initialize with a known value
72+
let INIT_VALUE: Field = 10;
73+
let initial_note = MockNote::new(INIT_VALUE).build_note();
74+
let _ = state_var.initialize(initial_note);
75+
76+
// Local variable to capture
77+
let x: Field = 5;
78+
79+
// Use a closure to increment the note by x
80+
let emission = state_var.replace(|note| MockNote::new(note.value + x).build_note());
81+
82+
// Expected value is initial + x
83+
let expected_value: Field = INIT_VALUE + x;
84+
let expected_note = MockNote::new(expected_value).build_note();
85+
86+
assert_eq(emission.note, expected_note);
3287
});
3388
}
3489

@@ -99,10 +154,12 @@ unconstrained fn initialize_and_replace_pending() {
99154
let note_hashes_pre_replace = context.note_hashes.len();
100155

101156
let replacement_value = VALUE + 1;
102-
let replacement_note = MockNote::new(replacement_value).build_note();
103-
let emission = state_var.replace(replacement_note);
104157

105-
assert_eq(emission.note, replacement_note);
158+
let emission =
159+
state_var.replace(|_old_note| MockNote::new(replacement_value).build_note());
160+
161+
let expected_note = MockNote::new(replacement_value).build_note();
162+
assert_eq(emission.note, expected_note);
106163
assert_eq(emission.storage_slot, STORAGE_SLOT);
107164

108165
// Replacing a PrivateMutable results in:
@@ -122,10 +179,13 @@ unconstrained fn initialize_or_replace_uninitialized() {
122179
env.private_context(|context| {
123180
let state_var = in_private(context);
124181

125-
let note = MockNote::new(VALUE).build_note();
126-
let emission = state_var.initialize_or_replace(note);
182+
let init_note = MockNote::new(VALUE).build_note();
127183

128-
assert_eq(emission.note, note);
184+
let emission = state_var.initialize_or_replace(init_note, |_old_note: MockNote| {
185+
panic(f"Unexpected call to replacement closure") // This should not be called
186+
});
187+
188+
assert_eq(emission.note, init_note);
129189
assert_eq(emission.storage_slot, STORAGE_SLOT);
130190

131191
// During initialization we both create the new note and emit the initialization nullifier. This will only
@@ -144,27 +204,23 @@ unconstrained fn initialize_or_replace_uninitialized() {
144204
// env.private_context(|context| {
145205
// let state_var = in_private(context);
146206

147-
// let note = MockNote::new(VALUE).build_note();
148-
149-
// let _ = state_var.initialize(note);
207+
// // Initialize with a known value
208+
// let init_note = MockNote::new(VALUE).build_note();
209+
// let _ = state_var.initialize(init_note);
150210

211+
// // Record context lengths before replacement
151212
// let note_hash_read_requests_pre_replace = context.note_hash_read_requests.len();
152213
// let nullifiers_pre_replace = context.nullifiers.len();
153214
// let note_hashes_pre_replace = context.note_hashes.len();
154215

155-
// let replacement_value = VALUE + 1;
156-
// let replacement_note = MockNote::new(replacement_value).build_note();
157-
// let emission = state_var.initialize_or_replace(replacement_note);
216+
// // Use initialize_or_replace with the helper function
217+
// let emission = state_var.initialize_or_replace(init_note, plus_one);
158218

159-
// assert_eq(emission.note, replacement_note);
160-
// assert_eq(emission.storage_slot, STORAGE_SLOT);
219+
// let expected_note = MockNote::new(VALUE + 1).build_note();
220+
// assert_eq(emission.note, expected_note);
221+
// // assert_eq(emission.storage_slot, STORAGE_SLOT);
161222

162-
// // Replacing a PrivateMutable results in:
163-
// // - a read request for the read value
164-
// // - a nullifier for the read note
165-
// // - a new note for the replacement note
166-
// // This would only succeed if the variable had already been initialized, as otherwise the read request would
167-
// // fail.
223+
// // Verify context updates: read request, nullifier, and new note
168224
// assert_eq(context.note_hash_read_requests.len(), note_hash_read_requests_pre_replace + 1);
169225
// assert_eq(context.nullifiers.len(), nullifiers_pre_replace + 1);
170226
// assert_eq(context.note_hashes.len(), note_hashes_pre_replace + 1);

noir-projects/noir-contracts/contracts/app/app_subscription_contract/src/main.nr

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -79,16 +79,12 @@ pub contract AppSubscription {
7979
// the parent takes. See aztec-nr/src/authwit/auth.nr for more details.
8080
assert_current_call_valid_authwit::<2>(&mut context, user_address);
8181

82-
let mut note = storage.subscriptions.at(user_address).get_note().note;
83-
assert(note.remaining_txs > 0, "you're out of txs");
84-
85-
note.remaining_txs -= 1;
86-
87-
storage.subscriptions.at(user_address).replace(note).emit(
88-
&mut context,
89-
user_address,
90-
MessageDelivery.CONSTRAINED_ONCHAIN,
91-
);
82+
let emission = storage.subscriptions.at(user_address).replace(|mut note| {
83+
assert(note.remaining_txs > 0, "you're out of txs");
84+
note.remaining_txs -= 1;
85+
note
86+
});
87+
emission.emit(&mut context, user_address, MessageDelivery.CONSTRAINED_ONCHAIN);
9288

9389
context.set_as_fee_payer();
9490

@@ -101,7 +97,11 @@ pub contract AppSubscription {
10197

10298
// We check that the note is not expired. We do that via the router contract to conceal which contract
10399
// is performing the check.
104-
privately_check_block_number(Comparator.LT, note.expiry_block_number, &mut context);
100+
privately_check_block_number(
101+
Comparator.LT,
102+
emission.note.expiry_block_number,
103+
&mut context,
104+
);
105105

106106
payload.execute_calls(&mut context, config.target_address);
107107
}
@@ -155,11 +155,11 @@ pub contract AppSubscription {
155155
);
156156

157157
let subscription_note = SubscriptionNote::new(subscriber, expiry_block_number, tx_count);
158-
storage.subscriptions.at(subscriber).initialize_or_replace(subscription_note).emit(
159-
&mut context,
160-
subscriber,
161-
MessageDelivery.CONSTRAINED_ONCHAIN,
162-
);
158+
storage
159+
.subscriptions
160+
.at(subscriber)
161+
.initialize_or_replace(subscription_note, |_old| subscription_note)
162+
.emit(&mut context, subscriber, MessageDelivery.CONSTRAINED_ONCHAIN);
163163
}
164164

165165
#[utility]

0 commit comments

Comments
 (0)