Skip to content

Commit b0d1214

Browse files
authored
fix: address node crash in v0.11.3 (#1312)
1 parent 954ef04 commit b0d1214

File tree

9 files changed

+373
-379
lines changed

9 files changed

+373
-379
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## v0.11.4 (TBD)
44

55
- Reduced note retries to 1 ([#1308](https://github.com/0xMiden/miden-node/pull/1308)).
6+
- Address network transaction builder (NTX) invariant breaking for unavailable accounts ([#1312](https://github.com/0xMiden/miden-node/pull/1312)).
67

78
## v0.11.3 (2025-10-09)
89

bin/remote-prover/src/proxy/worker.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ impl Worker {
205205
self.address(),
206206
e
207207
);
208-
return Err(e.to_string());
208+
return Err(e.clone());
209209
},
210210
};
211211

crates/block-producer/src/mempool/inflight_state/mod.rs

Lines changed: 4 additions & 353 deletions
Large diffs are not rendered by default.
Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
use assert_matches::assert_matches;
2+
use miden_node_utils::ErrorReport;
3+
use miden_objects::Word;
4+
5+
use super::*;
6+
use crate::test_utils::note::{mock_note, mock_output_note};
7+
use crate::test_utils::{MockProvenTxBuilder, mock_account_id};
8+
9+
#[test]
10+
fn rejects_expired_transaction() {
11+
let chain_tip = BlockNumber::from(123);
12+
let mut uut = InflightState::new(chain_tip, 5, 0u32);
13+
14+
let expired = MockProvenTxBuilder::with_account_index(0)
15+
.expiration_block_num(chain_tip)
16+
.build();
17+
let expired =
18+
AuthenticatedTransaction::from_inner(expired).with_authentication_height(chain_tip);
19+
20+
let err = uut.add_transaction(&expired).unwrap_err();
21+
assert_matches!(err, AddTransactionError::Expired { .. });
22+
}
23+
24+
/// Ensures that the specified expiration slack is adhered to.
25+
#[test]
26+
fn expiration_slack_is_respected() {
27+
let slack = 3;
28+
let chain_tip = BlockNumber::from(123);
29+
let expiration_limit = chain_tip + slack;
30+
let mut uut = InflightState::new(chain_tip, 5, slack);
31+
32+
let unexpired = MockProvenTxBuilder::with_account_index(0)
33+
.expiration_block_num(expiration_limit + 1)
34+
.build();
35+
let unexpired =
36+
AuthenticatedTransaction::from_inner(unexpired).with_authentication_height(chain_tip);
37+
38+
uut.add_transaction(&unexpired).unwrap();
39+
40+
let expired = MockProvenTxBuilder::with_account_index(1)
41+
.expiration_block_num(expiration_limit)
42+
.build();
43+
let expired =
44+
AuthenticatedTransaction::from_inner(expired).with_authentication_height(chain_tip);
45+
46+
let err = uut.add_transaction(&expired).unwrap_err();
47+
assert_matches!(err, AddTransactionError::Expired { .. });
48+
}
49+
50+
#[test]
51+
fn rejects_duplicate_nullifiers() {
52+
let account = mock_account_id(1);
53+
let states = (1u8..=4).map(|x| Word::from([x, 0, 0, 0])).collect::<Vec<_>>();
54+
55+
let note_seed = 123;
56+
// We need to make the note available first, in order for it to be consumed at all.
57+
let tx0 = MockProvenTxBuilder::with_account(account, states[0], states[1])
58+
.output_notes(vec![mock_output_note(note_seed)])
59+
.build();
60+
let tx1 = MockProvenTxBuilder::with_account(account, states[1], states[2])
61+
.unauthenticated_notes(vec![mock_note(note_seed)])
62+
.build();
63+
let tx2 = MockProvenTxBuilder::with_account(account, states[2], states[3])
64+
.unauthenticated_notes(vec![mock_note(note_seed)])
65+
.build();
66+
67+
let mut uut = InflightState::new(BlockNumber::default(), 1, 0u32);
68+
uut.add_transaction(&AuthenticatedTransaction::from_inner(tx0)).unwrap();
69+
uut.add_transaction(&AuthenticatedTransaction::from_inner(tx1)).unwrap();
70+
71+
let err = uut.add_transaction(&AuthenticatedTransaction::from_inner(tx2)).unwrap_err();
72+
73+
assert_matches!(
74+
err,
75+
AddTransactionError::VerificationFailed(VerifyTxError::InputNotesAlreadyConsumed(
76+
notes
77+
)) if notes == vec![mock_note(note_seed).nullifier()]
78+
);
79+
}
80+
81+
#[test]
82+
fn rejects_duplicate_output_notes() {
83+
let account = mock_account_id(1);
84+
let states = (1u8..=3).map(|x| Word::from([x, 0, 0, 0])).collect::<Vec<_>>();
85+
86+
let note = mock_output_note(123);
87+
let tx0 = MockProvenTxBuilder::with_account(account, states[0], states[1])
88+
.output_notes(vec![note.clone()])
89+
.build();
90+
let tx1 = MockProvenTxBuilder::with_account(account, states[1], states[2])
91+
.output_notes(vec![note.clone()])
92+
.build();
93+
94+
let mut uut = InflightState::new(BlockNumber::default(), 1, 0u32);
95+
uut.add_transaction(&AuthenticatedTransaction::from_inner(tx0)).unwrap();
96+
97+
let err = uut.add_transaction(&AuthenticatedTransaction::from_inner(tx1)).unwrap_err();
98+
99+
assert_matches!(
100+
err,
101+
AddTransactionError::VerificationFailed(VerifyTxError::OutputNotesAlreadyExist(
102+
notes
103+
)) if notes == vec![note.id()]
104+
);
105+
}
106+
107+
#[test]
108+
fn rejects_account_state_mismatch() {
109+
let account = mock_account_id(1);
110+
let states = (1u8..=3).map(|x| Word::from([x, 0, 0, 0])).collect::<Vec<_>>();
111+
112+
let tx = MockProvenTxBuilder::with_account(account, states[0], states[1]).build();
113+
114+
let mut uut = InflightState::new(BlockNumber::default(), 1, 0u32);
115+
let err = uut
116+
.add_transaction(&AuthenticatedTransaction::from_inner(tx).with_store_state(states[2]))
117+
.unwrap_err();
118+
119+
assert_matches!(
120+
err,
121+
AddTransactionError::VerificationFailed(VerifyTxError::IncorrectAccountInitialCommitment {
122+
tx_initial_account_commitment: init_state,
123+
current_account_commitment: current_state,
124+
}) if init_state == states[0] && current_state == states[2].into()
125+
);
126+
}
127+
128+
#[test]
129+
fn account_state_transitions() {
130+
let account = mock_account_id(1);
131+
let states = (1u8..=3).map(|x| Word::from([x, 0, 0, 0])).collect::<Vec<_>>();
132+
133+
let tx0 = MockProvenTxBuilder::with_account(account, states[0], states[1]).build();
134+
let tx1 = MockProvenTxBuilder::with_account(account, states[1], states[2]).build();
135+
136+
let mut uut = InflightState::new(BlockNumber::default(), 1, 0u32);
137+
uut.add_transaction(&AuthenticatedTransaction::from_inner(tx0)).unwrap();
138+
uut.add_transaction(&AuthenticatedTransaction::from_inner(tx1).with_empty_store_state())
139+
.unwrap();
140+
}
141+
142+
#[test]
143+
fn new_account_state_defaults_to_zero() {
144+
let account = mock_account_id(1);
145+
146+
let tx =
147+
MockProvenTxBuilder::with_account(account, [0u8, 0, 0, 0].into(), [1u8, 0, 0, 0].into())
148+
.build();
149+
150+
let mut uut = InflightState::new(BlockNumber::default(), 1, 0u32);
151+
uut.add_transaction(&AuthenticatedTransaction::from_inner(tx).with_empty_store_state())
152+
.unwrap();
153+
}
154+
155+
#[test]
156+
fn inflight_account_state_overrides_input_state() {
157+
let account = mock_account_id(1);
158+
let states = (1u8..=3).map(|x| Word::from([x, 0, 0, 0])).collect::<Vec<_>>();
159+
160+
let tx0 = MockProvenTxBuilder::with_account(account, states[0], states[1]).build();
161+
let tx1 = MockProvenTxBuilder::with_account(account, states[1], states[2]).build();
162+
163+
let mut uut = InflightState::new(BlockNumber::default(), 1, 0u32);
164+
uut.add_transaction(&AuthenticatedTransaction::from_inner(tx0)).unwrap();
165+
166+
// Feed in an old state via input. This should be ignored, and the previous tx's final
167+
// state should be used.
168+
uut.add_transaction(&AuthenticatedTransaction::from_inner(tx1).with_store_state(states[0]))
169+
.unwrap();
170+
}
171+
172+
#[test]
173+
fn dependency_tracking() {
174+
let account = mock_account_id(1);
175+
let states = (1u8..=3).map(|x| Word::from([x, 0, 0, 0])).collect::<Vec<_>>();
176+
let note_seed = 123;
177+
178+
// Parent via account state.
179+
let tx0 = MockProvenTxBuilder::with_account(account, states[0], states[1]).build();
180+
// Parent via output note.
181+
let tx1 = MockProvenTxBuilder::with_account(mock_account_id(2), states[0], states[1])
182+
.output_notes(vec![mock_output_note(note_seed)])
183+
.build();
184+
185+
let tx = MockProvenTxBuilder::with_account(account, states[1], states[2])
186+
.unauthenticated_notes(vec![mock_note(note_seed)])
187+
.build();
188+
189+
let mut uut = InflightState::new(BlockNumber::default(), 1, 0u32);
190+
uut.add_transaction(&AuthenticatedTransaction::from_inner(tx0.clone())).unwrap();
191+
uut.add_transaction(&AuthenticatedTransaction::from_inner(tx1.clone())).unwrap();
192+
193+
let parents = uut
194+
.add_transaction(&AuthenticatedTransaction::from_inner(tx).with_empty_store_state())
195+
.unwrap();
196+
let expected = BTreeSet::from([tx0.id(), tx1.id()]);
197+
198+
assert_eq!(parents, expected);
199+
}
200+
201+
#[test]
202+
fn committed_parents_are_not_tracked() {
203+
let account = mock_account_id(1);
204+
let states = (1u8..=3).map(|x| Word::from([x, 0, 0, 0])).collect::<Vec<_>>();
205+
let note_seed = 123;
206+
207+
// Parent via account state.
208+
let tx0 = MockProvenTxBuilder::with_account(account, states[0], states[1]).build();
209+
let tx0 = AuthenticatedTransaction::from_inner(tx0);
210+
// Parent via output note.
211+
let tx1 = MockProvenTxBuilder::with_account(mock_account_id(2), states[0], states[1])
212+
.output_notes(vec![mock_output_note(note_seed)])
213+
.build();
214+
let tx1 = AuthenticatedTransaction::from_inner(tx1);
215+
216+
let tx = MockProvenTxBuilder::with_account(account, states[1], states[2])
217+
.unauthenticated_notes(vec![mock_note(note_seed)])
218+
.build();
219+
220+
let mut uut = InflightState::new(BlockNumber::default(), 1, 0u32);
221+
uut.add_transaction(&tx0.clone()).unwrap();
222+
uut.add_transaction(&tx1.clone()).unwrap();
223+
224+
// Commit the parents, which should remove them from dependency tracking.
225+
uut.commit_block([tx0.id(), tx1.id()]);
226+
227+
let parents = uut
228+
.add_transaction(&AuthenticatedTransaction::from_inner(tx).with_empty_store_state())
229+
.unwrap();
230+
231+
assert!(parents.is_empty());
232+
}
233+
234+
#[test]
235+
fn tx_insertions_and_reversions_cancel_out() {
236+
// Reverting txs should be equivalent to them never being inserted.
237+
//
238+
// We test this by reverting some txs and equating it to the remaining set.
239+
// This is a form of property test.
240+
let states = (1u8..=5).map(|x| Word::from([x, 0, 0, 0])).collect::<Vec<_>>();
241+
let txs = vec![
242+
MockProvenTxBuilder::with_account(mock_account_id(1), states[0], states[1]),
243+
MockProvenTxBuilder::with_account(mock_account_id(1), states[1], states[2])
244+
.output_notes(vec![mock_output_note(111), mock_output_note(222)]),
245+
MockProvenTxBuilder::with_account(mock_account_id(2), states[0], states[1])
246+
.unauthenticated_notes(vec![mock_note(222)]),
247+
MockProvenTxBuilder::with_account(mock_account_id(1), states[2], states[3]),
248+
MockProvenTxBuilder::with_account(mock_account_id(2), states[1], states[2])
249+
.unauthenticated_notes(vec![mock_note(111)])
250+
.output_notes(vec![mock_output_note(45)]),
251+
];
252+
253+
let txs = txs
254+
.into_iter()
255+
.map(MockProvenTxBuilder::build)
256+
.map(AuthenticatedTransaction::from_inner)
257+
.collect::<Vec<_>>();
258+
259+
for i in 0..states.len() {
260+
// Insert all txs and then revert the last `i` of them.
261+
// This should match only inserting the first `N-i` of them.
262+
let mut reverted = InflightState::new(BlockNumber::default(), 1, 0u32);
263+
for (idx, tx) in txs.iter().enumerate() {
264+
reverted.add_transaction(tx).unwrap_or_else(|err| {
265+
panic!("Inserting tx #{idx} in iteration {i} should succeed: {}", err.as_report())
266+
});
267+
}
268+
reverted.revert_transactions(
269+
txs.iter().rev().take(i).rev().map(AuthenticatedTransaction::id).collect(),
270+
);
271+
272+
let mut inserted = InflightState::new(BlockNumber::default(), 1, 0u32);
273+
for (idx, tx) in txs.iter().rev().skip(i).rev().enumerate() {
274+
inserted.add_transaction(tx).unwrap_or_else(|err| {
275+
panic!("Inserting tx #{idx} in iteration {i} should succeed: {}", err.as_report())
276+
});
277+
}
278+
279+
assert_eq!(reverted, inserted, "Iteration {i}");
280+
}
281+
}
282+
283+
#[test]
284+
fn pruning_committed_state() {
285+
//! This is a form of property test, where we assert that pruning the first `i` of `N`
286+
//! transactions is equivalent to only inserting the last `N-i` transactions.
287+
let states = (1u8..=5).map(|x| Word::from([x, 0, 0, 0])).collect::<Vec<_>>();
288+
289+
// Skipping initial txs means that output notes required for subsequent unauthenticated
290+
// input notes wont' always be present. To work around this, we instead only use
291+
// authenticated input notes.
292+
let txs = vec![
293+
MockProvenTxBuilder::with_account(mock_account_id(1), states[0], states[1]),
294+
MockProvenTxBuilder::with_account(mock_account_id(1), states[1], states[2])
295+
.output_notes(vec![mock_output_note(111), mock_output_note(222)]),
296+
MockProvenTxBuilder::with_account(mock_account_id(2), states[0], states[1])
297+
.nullifiers(vec![mock_note(222).nullifier()]),
298+
MockProvenTxBuilder::with_account(mock_account_id(1), states[2], states[3]),
299+
MockProvenTxBuilder::with_account(mock_account_id(2), states[1], states[2])
300+
.nullifiers(vec![mock_note(111).nullifier()])
301+
.output_notes(vec![mock_output_note(45)]),
302+
];
303+
304+
let txs = txs
305+
.into_iter()
306+
.map(MockProvenTxBuilder::build)
307+
.map(AuthenticatedTransaction::from_inner)
308+
.collect::<Vec<_>>();
309+
310+
for i in 0..states.len() {
311+
// Insert all txs and then commit and prune the first `i` of them.
312+
//
313+
// This should match only inserting the final `N-i` transactions.
314+
// Note: we force all committed state to immediately be pruned by setting
315+
// it to zero.
316+
let mut committed = InflightState::new(BlockNumber::default(), 0, 0u32);
317+
for (idx, tx) in txs.iter().enumerate() {
318+
committed.add_transaction(tx).unwrap_or_else(|err| {
319+
panic!("Inserting tx #{idx} in iteration {i} should succeed: {}", err.as_report())
320+
});
321+
}
322+
committed.commit_block(txs.iter().take(i).map(AuthenticatedTransaction::id));
323+
324+
let mut inserted = InflightState::new(BlockNumber::from(1), 0, 0u32);
325+
for (idx, tx) in txs.iter().skip(i).enumerate() {
326+
// We need to adjust the height since we are effectively at block "1" now.
327+
let tx = tx.clone().with_authentication_height(1.into());
328+
inserted.add_transaction(&tx).unwrap_or_else(|err| {
329+
panic!("Inserting tx #{idx} in iteration {i} should succeed: {}", err.as_report())
330+
});
331+
}
332+
333+
assert_eq!(committed, inserted, "Iteration {i}");
334+
}
335+
}

crates/ntx-builder/src/state/account.rs

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -177,27 +177,27 @@ impl AccountState {
177177
///
178178
/// The note data is retained until the nullifier is committed.
179179
///
180-
/// # Panics
181-
///
182-
/// Panics if the note does not exist or was already nullified.
183-
pub fn add_nullifier(&mut self, nullifier: Nullifier) {
184-
let note = self
185-
.available_notes
186-
.remove(&nullifier)
187-
.expect("note must be available to nullify");
188-
189-
self.nullified_notes.insert(nullifier, note);
180+
/// Returns `Err(())` if the note does not exist or was already nullified.
181+
pub fn add_nullifier(&mut self, nullifier: Nullifier) -> Result<(), ()> {
182+
if let Some(note) = self.available_notes.remove(&nullifier) {
183+
self.nullified_notes.insert(nullifier, note);
184+
Ok(())
185+
} else {
186+
tracing::warn!(%nullifier, "note must be available to nullify");
187+
Err(())
188+
}
190189
}
191190

192191
/// Marks a nullifier as being committed, removing the associated note data entirely.
193192
///
194-
/// # Panics
195-
///
196-
/// Panics if the associated note is not marked as nullified.
193+
/// Silently ignores the request if the nullifier is not present, which can happen
194+
/// if the note's transaction wasn't available when the nullifier was added.
197195
pub fn commit_nullifier(&mut self, nullifier: Nullifier) {
198-
self.nullified_notes
199-
.remove(&nullifier)
200-
.expect("committed nullified note should be in the nullified set");
196+
// we might not have this if we didn't add it with `add_nullifier`
197+
// in case it's transaction wasn't available in the first place.
198+
// It shouldn't happen practically, since we skip them if the
199+
// relevant account cannot be retrieved via `fetch`.
200+
let _ = self.nullified_notes.remove(&nullifier);
201201
}
202202

203203
/// Reverts a nullifier, marking the associated note as available again.

0 commit comments

Comments
 (0)