Skip to content

Commit a287a5a

Browse files
authored
chore: refactor TransactionHeader serialization (#1759)
* chore: refactor `TransactionHeader` proto serialization * docs: add changelog entry * chore: address PR comments * docs: add pending TODO
1 parent ef03637 commit a287a5a

File tree

11 files changed

+201
-140
lines changed

11 files changed

+201
-140
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
- Replaced NTX Builder's in-memory state management with SQLite-backed persistence; account states, notes, and transaction effects are now stored in the database and inflight state is purged on startup ([#1662](https://github.com/0xMiden/node/pull/1662)).
2929
- [BREAKING] Reworked `miden-remote-prover`, removing the `worker`/`proxy` distinction and simplifying to a `worker` with a request queue ([#1688](https://github.com/0xMiden/node/pull/1688)).
3030
- [BREAKING] Renamed `NoteRoot` protobuf message used in `GetNoteScriptByRoot` gRPC endpoints into `NoteScriptRoot` ([#1722](https://github.com/0xMiden/node/pull/1722)).
31+
- [BREAKING] Modified `TransactionHeader` serialization to allow converting back into the native type after serialization ([#1759](https://github.com/0xMiden/node/issues/1759)).
3132
- Removed `chain_tip` requirement from mempool subscription request ([#1771](https://github.com/0xMiden/node/pull/1771)).
3233

3334
### Fixes

crates/proto/src/domain/note.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use miden_protocol::note::{
55
Note,
66
NoteAttachment,
77
NoteDetails,
8+
NoteHeader,
89
NoteId,
910
NoteInclusionProof,
1011
NoteMetadata,
@@ -219,6 +220,35 @@ impl TryFrom<proto::note::Note> for Note {
219220
}
220221
}
221222

223+
// NOTE HEADER
224+
// ================================================================================================
225+
226+
impl From<NoteHeader> for proto::note::NoteHeader {
227+
fn from(header: NoteHeader) -> Self {
228+
Self {
229+
note_id: Some((&header.id()).into()),
230+
metadata: Some(header.into_metadata().into()),
231+
}
232+
}
233+
}
234+
235+
impl TryFrom<proto::note::NoteHeader> for NoteHeader {
236+
type Error = ConversionError;
237+
238+
fn try_from(value: proto::note::NoteHeader) -> Result<Self, Self::Error> {
239+
let note_id_word: Word = value
240+
.note_id
241+
.ok_or_else(|| proto::note::NoteHeader::missing_field(stringify!(note_id)))?
242+
.try_into()?;
243+
let metadata: NoteMetadata = value
244+
.metadata
245+
.ok_or_else(|| proto::note::NoteHeader::missing_field(stringify!(metadata)))?
246+
.try_into()?;
247+
248+
Ok(NoteHeader::new(NoteId::from_raw(note_id_word), metadata))
249+
}
250+
}
251+
222252
// NOTE SCRIPT
223253
// ================================================================================================
224254

crates/proto/src/domain/transaction.rs

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
use miden_protocol::Word;
2-
use miden_protocol::transaction::TransactionId;
2+
use miden_protocol::note::Nullifier;
3+
use miden_protocol::transaction::{InputNoteCommitment, TransactionId};
4+
use miden_protocol::utils::{Deserializable, Serializable};
35

4-
use crate::errors::ConversionError;
6+
use crate::errors::{ConversionError, MissingFieldHelper};
57
use crate::generated as proto;
68

79
// FROM TRANSACTION ID
@@ -56,3 +58,40 @@ impl TryFrom<proto::transaction::TransactionId> for TransactionId {
5658
.try_into()
5759
}
5860
}
61+
62+
// INPUT NOTE COMMITMENT
63+
// ================================================================================================
64+
65+
impl From<InputNoteCommitment> for proto::transaction::InputNoteCommitment {
66+
fn from(value: InputNoteCommitment) -> Self {
67+
Self {
68+
nullifier: Some(value.nullifier().into()),
69+
header: value.header().cloned().map(Into::into),
70+
}
71+
}
72+
}
73+
74+
impl TryFrom<proto::transaction::InputNoteCommitment> for InputNoteCommitment {
75+
type Error = ConversionError;
76+
77+
fn try_from(value: proto::transaction::InputNoteCommitment) -> Result<Self, Self::Error> {
78+
let nullifier: Nullifier = value
79+
.nullifier
80+
.ok_or_else(|| {
81+
proto::transaction::InputNoteCommitment::missing_field(stringify!(nullifier))
82+
})?
83+
.try_into()?;
84+
85+
let header: Option<miden_protocol::note::NoteHeader> =
86+
value.header.map(TryInto::try_into).transpose()?;
87+
88+
// TODO: https://github.com/0xMiden/node/issues/1783
89+
// InputNoteCommitment has private fields, so we reconstruct it via
90+
// serialization roundtrip using its Serializable/Deserializable impls.
91+
let mut bytes = Vec::new();
92+
nullifier.write_into(&mut bytes);
93+
header.write_into(&mut bytes);
94+
InputNoteCommitment::read_from_bytes(&bytes)
95+
.map_err(|err| ConversionError::deserialization_error("InputNoteCommitment", err))
96+
}
97+
}

crates/store/src/db/migrations/2025062000000_setup/up.sql

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,9 +146,10 @@ CREATE TABLE transactions (
146146
block_num INTEGER NOT NULL, -- Block number in which the transaction was included.
147147
initial_state_commitment BLOB NOT NULL, -- State of the account before applying the transaction.
148148
final_state_commitment BLOB NOT NULL, -- State of the account after applying the transaction.
149-
nullifiers BLOB NOT NULL, -- Serialized vector with the Nullifier of the input notes.
150-
output_notes BLOB NOT NULL, -- Serialized vector with the NoteId of the output notes.
149+
input_notes BLOB NOT NULL, -- Serialized Vec<InputNoteCommitment> (nullifier + optional NoteHeader).
150+
output_notes BLOB NOT NULL, -- Serialized Vec<NoteHeader> (NoteId + NoteMetadata).
151151
size_in_bytes INTEGER NOT NULL, -- Estimated size of the row in bytes, considering the size of the input and output notes.
152+
fee BLOB NOT NULL, -- Serialized FungibleAsset representing the fee paid by the transaction.
152153

153154
PRIMARY KEY (transaction_id)
154155
) WITHOUT ROWID;

crates/store/src/db/mod.rs

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,19 @@ use miden_node_utils::limiter::MAX_RESPONSE_PAYLOAD_BYTES;
1111
use miden_node_utils::tracing::OpenTelemetrySpanExt;
1212
use miden_protocol::Word;
1313
use miden_protocol::account::{AccountHeader, AccountId, AccountStorageHeader, StorageMapKey};
14-
use miden_protocol::asset::{Asset, AssetVaultKey};
14+
use miden_protocol::asset::{Asset, AssetVaultKey, FungibleAsset};
1515
use miden_protocol::block::{BlockHeader, BlockNoteIndex, BlockNumber, SignedBlock};
1616
use miden_protocol::crypto::merkle::SparseMerklePath;
1717
use miden_protocol::note::{
1818
NoteDetails,
19+
NoteHeader,
1920
NoteId,
2021
NoteInclusionProof,
2122
NoteMetadata,
2223
NoteScript,
2324
Nullifier,
2425
};
25-
use miden_protocol::transaction::TransactionId;
26+
use miden_protocol::transaction::{InputNoteCommitment, TransactionId};
2627
use miden_protocol::utils::{Deserializable, Serializable};
2728
use tokio::sync::oneshot;
2829
use tracing::{info, instrument};
@@ -141,27 +142,26 @@ pub struct TransactionRecord {
141142
pub account_id: AccountId,
142143
pub initial_state_commitment: Word,
143144
pub final_state_commitment: Word,
144-
pub nullifiers: Vec<Nullifier>, // Store nullifiers for input notes
145-
pub output_notes: Vec<NoteId>, // Store note IDs for output notes
145+
pub input_notes: Vec<InputNoteCommitment>,
146+
pub output_notes: Vec<NoteHeader>,
147+
pub fee: FungibleAsset,
146148
}
147149

148150
impl TransactionRecord {
149-
/// Convert to proto `TransactionRecord`, but requires note sync records for output notes.
150-
/// For `sync_transactions` RPC, we need to fetch note sync records separately since we only
151-
/// store note IDs in the database.
152-
pub fn into_proto_with_note_records(
153-
self,
154-
note_records: Vec<NoteRecord>,
155-
) -> proto::rpc::TransactionRecord {
156-
let output_notes = Vec::from_iter(note_records.into_iter().map(Into::into));
157-
151+
/// Convert to proto `TransactionRecord`.
152+
///
153+
/// The proto `TransactionHeader` is a 1:1 mapping of
154+
/// `miden_protocol::transaction::TransactionHeader`.
155+
pub fn into_proto(self) -> proto::rpc::TransactionRecord {
158156
proto::rpc::TransactionRecord {
159157
header: Some(proto::transaction::TransactionHeader {
158+
transaction_id: Some(self.transaction_id.into()),
160159
account_id: Some(self.account_id.into()),
161160
initial_state_commitment: Some(self.initial_state_commitment.into()),
162161
final_state_commitment: Some(self.final_state_commitment.into()),
163-
nullifiers: self.nullifiers.into_iter().map(From::from).collect(),
164-
output_notes,
162+
input_notes: self.input_notes.into_iter().map(Into::into).collect(),
163+
output_notes: self.output_notes.into_iter().map(Into::into).collect(),
164+
fee: Some(Asset::from(self.fee).into()),
165165
}),
166166
block_num: self.block_num.as_u32(),
167167
}

crates/store/src/db/models/queries/transactions.rs

Lines changed: 29 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ use miden_node_utils::limiter::{
1919
};
2020
use miden_protocol::account::AccountId;
2121
use miden_protocol::block::BlockNumber;
22-
use miden_protocol::note::{NoteId, Nullifier};
23-
use miden_protocol::transaction::{OrderedTransactionHeaders, TransactionId};
22+
use miden_protocol::note::NoteHeader;
23+
use miden_protocol::transaction::{InputNoteCommitment, OrderedTransactionHeaders, TransactionId};
2424
use miden_protocol::utils::{Deserializable, Serializable};
2525

2626
use super::DatabaseError;
@@ -38,33 +38,32 @@ pub struct TransactionRecordRaw {
3838
transaction_id: Vec<u8>,
3939
initial_state_commitment: Vec<u8>,
4040
final_state_commitment: Vec<u8>,
41-
nullifiers: Vec<u8>,
41+
input_notes: Vec<u8>,
4242
output_notes: Vec<u8>,
4343
size_in_bytes: i64,
44+
fee: Vec<u8>,
4445
}
4546

4647
impl TryInto<crate::db::TransactionRecord> for TransactionRecordRaw {
4748
type Error = DatabaseError;
4849
fn try_into(self) -> Result<crate::db::TransactionRecord, Self::Error> {
4950
use miden_protocol::Word;
51+
use miden_protocol::asset::FungibleAsset;
5052

51-
let initial_state_commitment = self.initial_state_commitment;
52-
let final_state_commitment = self.final_state_commitment;
53-
let nullifiers_binary = self.nullifiers;
54-
let output_notes_binary = self.output_notes;
55-
56-
// Deserialize input notes as nullifiers and output notes as note IDs
57-
let nullifiers: Vec<Nullifier> = Deserializable::read_from_bytes(&nullifiers_binary)?;
58-
let output_notes: Vec<NoteId> = Deserializable::read_from_bytes(&output_notes_binary)?;
53+
let input_notes: Vec<InputNoteCommitment> =
54+
Deserializable::read_from_bytes(&self.input_notes)?;
55+
let output_notes: Vec<NoteHeader> = Deserializable::read_from_bytes(&self.output_notes)?;
56+
let fee = FungibleAsset::read_from_bytes(&self.fee)?;
5957

6058
Ok(crate::db::TransactionRecord {
6159
account_id: AccountId::read_from_bytes(&self.account_id[..])?,
6260
block_num: BlockNumber::from_raw_sql(self.block_num)?,
6361
transaction_id: TransactionId::read_from_bytes(&self.transaction_id[..])?,
64-
initial_state_commitment: Word::read_from_bytes(&initial_state_commitment)?,
65-
final_state_commitment: Word::read_from_bytes(&final_state_commitment)?,
66-
nullifiers,
62+
initial_state_commitment: Word::read_from_bytes(&self.initial_state_commitment)?,
63+
final_state_commitment: Word::read_from_bytes(&self.final_state_commitment)?,
64+
input_notes,
6765
output_notes,
66+
fee,
6867
})
6968
}
7069
}
@@ -108,9 +107,10 @@ pub struct TransactionSummaryRowInsert {
108107
block_num: i64,
109108
initial_state_commitment: Vec<u8>,
110109
final_state_commitment: Vec<u8>,
111-
nullifiers: Vec<u8>,
110+
input_notes: Vec<u8>,
112111
output_notes: Vec<u8>,
113112
size_in_bytes: i64,
113+
fee: Vec<u8>,
114114
}
115115

116116
impl TransactionSummaryRowInsert {
@@ -124,50 +124,37 @@ impl TransactionSummaryRowInsert {
124124
) -> Self {
125125
const HEADER_BASE_SIZE: usize = 4 + 32 + 16 + 64; // block_num + tx_id + account_id + commitments
126126

127-
// Extract nullifiers from input notes and serialize them.
128-
// We only store the nullifiers (not the full `InputNoteCommitment`) since
129-
// that's all that's needed when reading back `TransactionRecords`.
130-
let nullifiers: Vec<Nullifier> = transaction_header
131-
.input_notes()
132-
.iter()
133-
.map(miden_protocol::transaction::InputNoteCommitment::nullifier)
134-
.collect();
135-
let nullifiers_binary = nullifiers.to_bytes();
127+
// Serialize input notes as full InputNoteCommitments (nullifier + optional NoteHeader).
128+
let input_notes: Vec<InputNoteCommitment> =
129+
transaction_header.input_notes().iter().cloned().collect();
130+
let input_notes_binary = input_notes.to_bytes();
136131

137-
// Extract note IDs from output note headers and serialize them.
138-
// We only store the `NoteId`s (not the full `NoteHeader` with metadata) since
139-
// that's all that's needed when reading back `TransactionRecords`.
140-
let output_note_ids: Vec<NoteId> = transaction_header
141-
.output_notes()
142-
.iter()
143-
.map(miden_protocol::note::NoteHeader::id)
144-
.collect();
145-
let output_notes_binary = output_note_ids.to_bytes();
132+
// Serialize output notes as full NoteHeaders (NoteId + NoteMetadata).
133+
let output_notes: Vec<NoteHeader> = transaction_header.output_notes().to_vec();
134+
let output_notes_binary = output_notes.to_bytes();
146135

147136
// Manually calculate the estimated size of the transaction header to avoid
148137
// the cost of serialization. The size estimation includes:
149138
// - 4 bytes for block number
150139
// - 32 bytes for transaction ID
151140
// - 16 bytes for account ID
152141
// - 64 bytes for initial + final state commitments (32 bytes each)
153-
// - 32 bytes per input note (nullifier size)
154-
// - 500 bytes per output note (estimated size when converted to NoteSyncRecord)
155-
//
156-
// Note: 500 bytes per output note is an over-estimate but ensures we don't
157-
// exceed memory limits when these transactions are later converted to proto records.
158-
let nullifiers_size = (transaction_header.input_notes().num_notes() * 32) as usize;
159-
let output_notes_size = transaction_header.output_notes().len() * 500;
160-
let size_in_bytes = (HEADER_BASE_SIZE + nullifiers_size + output_notes_size) as i64;
142+
// - ~64 bytes per input note (nullifier + optional NoteHeader)
143+
// - ~64 bytes per output note (NoteHeader = NoteId + NoteMetadata)
144+
let input_notes_size = (transaction_header.input_notes().num_notes() as usize) * 64;
145+
let output_notes_size = transaction_header.output_notes().len() * 64;
146+
let size_in_bytes = (HEADER_BASE_SIZE + input_notes_size + output_notes_size) as i64;
161147

162148
Self {
163149
transaction_id: transaction_header.id().to_bytes(),
164150
account_id: transaction_header.account_id().to_bytes(),
165151
block_num: block_num.to_raw_sql(),
166152
initial_state_commitment: transaction_header.initial_state_commitment().to_bytes(),
167153
final_state_commitment: transaction_header.final_state_commitment().to_bytes(),
168-
nullifiers: nullifiers_binary,
154+
input_notes: input_notes_binary,
169155
output_notes: output_notes_binary,
170156
size_in_bytes,
157+
fee: transaction_header.fee().to_bytes(),
171158
}
172159
}
173160
}

crates/store/src/db/schema.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,10 @@ diesel::table! {
9797
block_num -> BigInt,
9898
initial_state_commitment -> Binary,
9999
final_state_commitment -> Binary,
100-
nullifiers -> Binary,
100+
input_notes -> Binary,
101101
output_notes -> Binary,
102102
size_in_bytes -> BigInt,
103+
fee -> Binary,
103104
}
104105
}
105106

0 commit comments

Comments
 (0)