Skip to content

Commit 5fd8fd3

Browse files
committed
feat(aztec-nr): reorg-aware RetractableRegister backing the handshake sync cursor
1 parent d4f7768 commit 5fd8fd3

7 files changed

Lines changed: 358 additions & 79 deletions

File tree

noir-projects/aztec-nr/aztec/src/facts/mod.nr

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ use crate::protocol::{address::AztecAddress, traits::{Deserialize, Serialize}};
1414
mod origin_state;
1515
pub use origin_state::OriginBlockState;
1616

17+
mod retractable_register;
18+
pub use retractable_register::RetractableRegister;
19+
1720
/// The block a retractable fact originates from.
1821
#[derive(Deserialize, Eq, Serialize)]
1922
pub struct OriginBlock {
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
use crate::ephemeral::EphemeralArray;
2+
use crate::protocol::{address::AztecAddress, hash::sha256_to_field, traits::{Deserialize, Serialize}};
3+
use super::{
4+
delete_fact_collection, Fact, FactCollection, get_fact_collection, OriginBlock, record_retractable_fact,
5+
RetractableFactOrigin,
6+
};
7+
8+
/// Fact type id of a register version. Registers are distinguished by their collection type and id, so all of them
9+
/// share a single fact type.
10+
global RETRACTABLE_REGISTER_VALUE: Field = sha256_to_field("AZTEC_NR::RETRACTABLE_REGISTER_VALUE".as_bytes());
11+
12+
/// A reorg-aware mutable value backed by retractable facts.
13+
///
14+
/// Facts are immutable, so mutation is modeled as versioning: every [`write`](Self::write) appends a new version
15+
/// anchored at an origin block, and [`read`](Self::read) returns the version with the highest surviving origin.
16+
/// Because versions are retractable facts, a reorg deletes exactly the versions from pruned blocks and the previous
17+
/// surviving version becomes current again: rollback is performed by the store, never by the writer.
18+
///
19+
/// # Compaction
20+
///
21+
/// Versions originated at or before the finalized tip can never be retracted, so anything older than the newest
22+
/// finalized version can never become current again. [`write`](Self::write) discards that dead history. After the
23+
/// first finalized write, a reorg can therefore roll the register back at worst to that finalized version.
24+
pub struct RetractableRegister<T> {
25+
contract_address: AztecAddress,
26+
scope: AztecAddress,
27+
fact_collection_type_id: Field,
28+
fact_collection_id: Field,
29+
}
30+
31+
impl<T> RetractableRegister<T> {
32+
/// Returns the register stored in the given fact collection.
33+
pub unconstrained fn at(
34+
contract_address: AztecAddress,
35+
scope: AztecAddress,
36+
fact_collection_type_id: Field,
37+
fact_collection_id: Field,
38+
) -> Self {
39+
Self { contract_address, scope, fact_collection_type_id, fact_collection_id }
40+
}
41+
42+
/// Returns the current value and its origin block.
43+
///
44+
/// The current version is the one with the highest surviving origin. Returns `None` if no version survives:
45+
/// nothing was ever written, or a reorg retracted every version.
46+
pub unconstrained fn read(self) -> Option<(T, RetractableFactOrigin)>
47+
where
48+
T: Deserialize,
49+
{
50+
self.maybe_versions().map(|versions| {
51+
let current = current_version(versions);
52+
(deserialize_payload(current.payload), current.origin_block.unwrap())
53+
})
54+
}
55+
56+
/// Appends a new version anchored at `origin_block`.
57+
///
58+
/// Also discards history that finality has made unreachable. Reorging `origin_block` away retracts the version
59+
/// and the previous survivor becomes current.
60+
///
61+
/// # Panics
62+
/// If `origin_block` is strictly behind the current version's origin: the current version is the one with the
63+
/// highest origin block, not the most recently written, so a write anchored behind it would not take effect.
64+
/// Re-writing at the current version's origin is allowed: re-recording an identical fact is a no-op, which
65+
/// keeps rescans idempotent.
66+
pub unconstrained fn write(self, value: T, origin_block: OriginBlock)
67+
where
68+
T: Serialize,
69+
{
70+
let maybe_versions = self.maybe_versions();
71+
if maybe_versions.is_some() {
72+
let versions = maybe_versions.unwrap();
73+
assert(
74+
origin_block.block_number >= current_version(versions).origin_block.unwrap().block_number,
75+
"RetractableRegister write is anchored behind the current version",
76+
);
77+
self.discard_dead_history(versions);
78+
}
79+
80+
self.record_version(serialize_payload(value), origin_block);
81+
}
82+
83+
/// Returns every surviving version, or `None` if there is none.
84+
unconstrained fn maybe_versions(self) -> Option<EphemeralArray<Fact>> {
85+
get_fact_collection(
86+
self.contract_address,
87+
self.scope,
88+
self.fact_collection_type_id,
89+
self.fact_collection_id,
90+
)
91+
.map(|collection: FactCollection| collection.facts)
92+
}
93+
94+
/// Discards versions that finality has made unreachable.
95+
///
96+
/// Everything behind the newest finalized version can never become current again. The store has no per-fact
97+
/// deletion, so discarding means recreating the collection with the survivors.
98+
unconstrained fn discard_dead_history(self, versions: EphemeralArray<Fact>) {
99+
let maybe_newest_finalized = newest_finalized_block_number(versions);
100+
if maybe_newest_finalized.is_some() {
101+
let newest_finalized = maybe_newest_finalized.unwrap();
102+
if has_versions_behind(versions, newest_finalized) {
103+
delete_fact_collection(
104+
self.contract_address,
105+
self.scope,
106+
self.fact_collection_type_id,
107+
self.fact_collection_id,
108+
);
109+
versions
110+
.filter(|version| version.origin_block.unwrap().block_number >= newest_finalized)
111+
.for_each(|_i, version| {
112+
let origin = version.origin_block.unwrap();
113+
self.record_version(
114+
version.payload,
115+
OriginBlock { block_number: origin.block_number, block_hash: origin.block_hash },
116+
);
117+
});
118+
}
119+
}
120+
}
121+
122+
/// Records a version fact at the given origin block.
123+
unconstrained fn record_version(self, payload: EphemeralArray<Field>, origin_block: OriginBlock) {
124+
record_retractable_fact(
125+
self.contract_address,
126+
self.scope,
127+
self.fact_collection_type_id,
128+
self.fact_collection_id,
129+
RETRACTABLE_REGISTER_VALUE,
130+
payload,
131+
origin_block,
132+
);
133+
}
134+
}
135+
136+
/// Returns the current version (highest origin block).
137+
unconstrained fn current_version(versions: EphemeralArray<Fact>) -> Fact {
138+
// Writes are anchored at monotonically non-decreasing origins (enforced by `write`) and the store preserves
139+
// insertion order across retraction and compaction, so the last surviving version is current.
140+
versions.get(versions.len() - 1)
141+
}
142+
143+
/// Returns whether any version is strictly behind `block_number`.
144+
unconstrained fn has_versions_behind(versions: EphemeralArray<Fact>, block_number: u32) -> bool {
145+
versions.any(|version| version.origin_block.unwrap().block_number < block_number)
146+
}
147+
148+
/// Returns the newest finalized origin block number, if any.
149+
unconstrained fn newest_finalized_block_number(versions: EphemeralArray<Fact>) -> Option<u32> {
150+
let finalized = versions.filter(|version| version.origin_block.unwrap().block_state.is_finalized());
151+
if finalized.len() == 0 {
152+
Option::none()
153+
} else {
154+
// Versions are ordered by origin, so the last finalized one is the newest.
155+
Option::some(
156+
finalized.get(finalized.len() - 1).origin_block.unwrap().block_number,
157+
)
158+
}
159+
}
160+
161+
unconstrained fn serialize_payload<T>(value: T) -> EphemeralArray<Field>
162+
where
163+
T: Serialize,
164+
{
165+
let fields = value.serialize();
166+
let payload = EphemeralArray::empty();
167+
for i in 0..fields.len() {
168+
payload.push(fields[i]);
169+
}
170+
payload
171+
}
172+
173+
unconstrained fn deserialize_payload<T>(payload: EphemeralArray<Field>) -> T
174+
where
175+
T: Deserialize,
176+
{
177+
let n = <T as Deserialize>::N;
178+
let mut fields = [0; <T as Deserialize>::N];
179+
for i in 0..n {
180+
fields[i] = payload.get(i);
181+
}
182+
T::deserialize(fields)
183+
}
184+
185+
mod test {
186+
use crate::facts::{get_fact_collection, OriginBlock};
187+
use crate::protocol::address::AztecAddress;
188+
use crate::test::helpers::test_environment::TestEnvironment;
189+
use super::RetractableRegister;
190+
191+
global TYPE_ID: Field = 4242;
192+
global COLLECTION_ID: Field = 7;
193+
194+
unconstrained fn setup() -> (TestEnvironment, AztecAddress) {
195+
let mut env = TestEnvironment::new();
196+
let scope = env.create_light_account();
197+
(env, scope)
198+
}
199+
200+
unconstrained fn register_at(contract_address: AztecAddress, scope: AztecAddress) -> RetractableRegister<Field> {
201+
RetractableRegister::at(contract_address, scope, TYPE_ID, COLLECTION_ID)
202+
}
203+
204+
#[test]
205+
unconstrained fn unwritten_register_reads_none() {
206+
let (env, scope) = setup();
207+
env.private_context(|context| {
208+
let register = register_at(context.this_address(), scope);
209+
assert(register.read().is_none());
210+
});
211+
}
212+
213+
#[test]
214+
unconstrained fn reads_back_written_value_and_origin() {
215+
let (env, scope) = setup();
216+
let block_number = env.last_block_number();
217+
env.private_context(|context| {
218+
let register = register_at(context.this_address(), scope);
219+
register.write(123, OriginBlock { block_number, block_hash: 0xabc });
220+
221+
let (value, origin) = register.read().unwrap();
222+
assert_eq(value, 123);
223+
assert_eq(origin.block_number, block_number);
224+
assert_eq(origin.block_hash, 0xabc);
225+
});
226+
}
227+
228+
#[test]
229+
unconstrained fn newest_origin_wins() {
230+
let (env, scope) = setup();
231+
let block_number = env.last_block_number();
232+
env.private_context(|context| {
233+
let register = register_at(context.this_address(), scope);
234+
register.write(1, OriginBlock { block_number, block_hash: 0xabc });
235+
register.write(2, OriginBlock { block_number: block_number + 50, block_hash: 0xdef });
236+
237+
let (value, origin) = register.read().unwrap();
238+
assert_eq(value, 2);
239+
assert_eq(origin.block_number, block_number + 50);
240+
});
241+
}
242+
243+
#[test(should_fail_with = "anchored behind the current version")]
244+
unconstrained fn write_behind_the_current_version_panics() {
245+
let (env, scope) = setup();
246+
let block_number = env.last_block_number();
247+
env.private_context(|context| {
248+
let register = register_at(context.this_address(), scope);
249+
register.write(1, OriginBlock { block_number: block_number + 50, block_hash: 0xabc });
250+
register.write(2, OriginBlock { block_number, block_hash: 0xdef });
251+
});
252+
}
253+
254+
#[test]
255+
unconstrained fn rewriting_at_the_current_origin_is_a_no_op() {
256+
let (env, scope) = setup();
257+
let block_number = env.last_block_number();
258+
env.private_context(|context| {
259+
let register = register_at(context.this_address(), scope);
260+
register.write(1, OriginBlock { block_number, block_hash: 0xabc });
261+
register.write(1, OriginBlock { block_number, block_hash: 0xabc });
262+
263+
let (value, _) = register.read().unwrap();
264+
assert_eq(value, 1);
265+
let facts = get_fact_collection(context.this_address(), scope, TYPE_ID, COLLECTION_ID).unwrap().facts;
266+
assert_eq(facts.len(), 1);
267+
});
268+
}
269+
270+
#[test]
271+
unconstrained fn pending_versions_are_kept() {
272+
let (env, scope) = setup();
273+
let block_number = env.last_block_number();
274+
env.private_context(|context| {
275+
let register = register_at(context.this_address(), scope);
276+
register.write(1, OriginBlock { block_number, block_hash: 0xabc });
277+
register.write(2, OriginBlock { block_number: block_number + 50, block_hash: 0xdef });
278+
register.write(3, OriginBlock { block_number: block_number + 100, block_hash: 0x123 });
279+
280+
// All origins are above the proven tip, hence pending: none is finalized dead history, so every version
281+
// is kept as a reorg fallback.
282+
let facts = get_fact_collection(context.this_address(), scope, TYPE_ID, COLLECTION_ID).unwrap().facts;
283+
assert_eq(facts.len(), 3);
284+
});
285+
}
286+
287+
#[test]
288+
unconstrained fn collapses_history_behind_newest_finalized_version() {
289+
let (mut env, scope) = setup();
290+
env.mine_block();
291+
env.mine_block();
292+
let block_number = env.last_block_number();
293+
env.private_context(|context| {
294+
let register = register_at(context.this_address(), scope);
295+
register.write(1, OriginBlock { block_number: block_number - 2, block_hash: 0xabc });
296+
register.write(2, OriginBlock { block_number: block_number - 1, block_hash: 0xdef });
297+
register.write(3, OriginBlock { block_number, block_hash: 0x123 });
298+
299+
// TXE finalizes every mined block, so all three origins are finalized and each write discards history
300+
// behind the newest finalized version, leaving the last two.
301+
let facts = get_fact_collection(context.this_address(), scope, TYPE_ID, COLLECTION_ID).unwrap().facts;
302+
assert_eq(facts.len(), 2);
303+
assert_eq(facts.get(0).origin_block.unwrap().block_number, block_number - 1);
304+
assert_eq(facts.get(1).origin_block.unwrap().block_number, block_number);
305+
306+
let (value, _) = register.read().unwrap();
307+
assert_eq(value, 3);
308+
});
309+
}
310+
}

noir-projects/aztec-nr/aztec/src/standard_addresses.nr

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,17 @@
22
use protocol_types::{address::AztecAddress, traits::FromField};
33

44
pub global STANDARD_AUTH_REGISTRY_ADDRESS: AztecAddress = AztecAddress::from_field(
5-
0x23eb6e1c966b3fe333bcbdbf25d6f4d4fdf3726cca3a9892e461c348bdb21666,
5+
0x064ae55c68e9b7050dc770ba54d283ac92573e2dd4fe92dd1ed78490ee754aad,
66
);
77

88
pub global STANDARD_MULTI_CALL_ENTRYPOINT_ADDRESS: AztecAddress = AztecAddress::from_field(
9-
0x12d0aab3c4488b825f9b92c10865ac26c969fd3ddbcc0dbd6c39d5893cc59f4b,
9+
0x17492617477ffe69e4c86e0254bee88ca6476ada9508ed964db1aafcb44958c4,
1010
);
1111

1212
pub global STANDARD_PUBLIC_CHECKS_ADDRESS: AztecAddress = AztecAddress::from_field(
13-
0x02bd15136aa4ea1ad6113aa5bfb81add7e986c85e1cdcc312c00ac1bb653984d,
13+
0x03d6f89679b01b337ed9a4ef41eb44670c369f2873a80f13d0d4818072c6f8dc,
1414
);
1515

1616
pub global STANDARD_HANDSHAKE_REGISTRY_ADDRESS: AztecAddress = AztecAddress::from_field(
17-
0x01daf0f4a46ca4bf67594741832c57dbea5dda829326a7a0f4278b58797eefad,
17+
0x10633cb9087241fc0709264acff05505aa28b7b0a4663a68e7e9b390949686fd,
1818
);

0 commit comments

Comments
 (0)