diff --git a/core/src/block/genesis.rs b/core/src/block/genesis.rs index efde4dd85..587edf4b6 100644 --- a/core/src/block/genesis.rs +++ b/core/src/block/genesis.rs @@ -1,13 +1,14 @@ use std::fmt::{Debug, Formatter}; use lb_key_management_system_keys::keys::Ed25519Signature; +use lb_utils::bounded_vec::BoundedError; use crate::{ block::Block, header::Header, mantle::{ MantleTx, Note, Op, OpProof, SignedMantleTx, - encoding::Ops, + encoding::{BoundedOutputs, Ops}, genesis_tx::{self, GenesisTx}, ledger::{Inputs, Outputs}, ops::{channel::inscribe::InscriptionOp, sdp::SDPDeclareOp, transfer::TransferOp}, @@ -27,12 +28,62 @@ pub enum Error { /// unsupported ops). #[error("Invalid genesis transaction: {0}")] InvalidGenesisTx(#[from] genesis_tx::Error), + #[error("add_notes called with empty iterator")] + EmptyNotes, + #[error("too many notes for genesis transfer outputs: attempted {actual}, max {max}")] + TooManyNotes { actual: usize, max: usize }, } /// Convenience [`Result`](core::result::Result) alias for genesis block /// construction. pub type Result = core::result::Result; +const fn map_notes_bounded_error(error: &BoundedError) -> Error { + match error { + BoundedError::TooLong { actual, max } => Error::TooManyNotes { + actual: *actual, + max: *max, + }, + BoundedError::EmptyInput => Error::EmptyNotes, + } +} + +fn collect_non_empty_notes(notes: I) -> Result +where + I: IntoIterator, + N: Into, +{ + let notes: Vec = notes.into_iter().map(Into::into).collect(); + if notes.is_empty() { + return Err(Error::EmptyNotes); + } + BoundedOutputs::try_from(notes).map_err(|error| map_notes_bounded_error(&error)) +} + +fn push_note(mut notes: BoundedOutputs, note: Note) -> Result { + notes + .try_push(note) + .map_err(|error| map_notes_bounded_error(&error))?; + Ok(notes) +} + +fn extend_non_empty_notes(mut existing: BoundedOutputs, notes: I) -> Result +where + I: IntoIterator, + N: Into, +{ + let mut iter = notes.into_iter().peekable(); + if iter.peek().is_none() { + return Err(Error::EmptyNotes); + } + for note in iter.map(Into::into) { + existing + .try_push(note) + .map_err(|error| map_notes_bounded_error(&error))?; + } + Ok(existing) +} + /// A [`Block`] whose transactions are all [`GenesisTx`] values. /// /// The block carries a sentinel @@ -78,7 +129,7 @@ pub struct WithGenesisTx { /// Typestate marker: builder has genesis transfer output notes only. pub struct WithNotes { - notes: Vec, + notes: BoundedOutputs, } /// Typestate marker: builder has a genesis inscription only. @@ -93,13 +144,13 @@ pub struct WithDeclarations { /// Typestate marker: builder has genesis notes and an inscription. pub struct WithNotesAndInscription { - notes: Vec, + notes: BoundedOutputs, inscription: InscriptionOp, } /// Typestate marker: builder has genesis notes and SDP declarations. pub struct WithNotesAndDeclarations { - notes: Vec, + notes: BoundedOutputs, sdp_declarations: Vec, } @@ -117,7 +168,7 @@ pub struct WithInscriptionAndDeclarations { /// [`GenesisTx`] — notes, an inscription, and at least one SDP declaration. /// This is the only state that exposes [`GenesisBlockBuilder::build`]. pub struct WithAll { - notes: Vec, + notes: BoundedOutputs, inscription: InscriptionOp, sdp_declarations: Vec, } @@ -197,29 +248,32 @@ impl GenesisBlockBuilder { #[must_use] pub fn add_note(self, note: Note) -> GenesisBlockBuilder { GenesisBlockBuilder { - state: WithNotes { notes: vec![note] }, + state: WithNotes { + notes: [note].into(), + }, } } + /// Try add multiple genesis transfer output notes at once, transitioning to + /// [`WithNotes`]. + pub fn try_add_notes( + self, + notes: impl IntoIterator>, + ) -> Result> { + let notes = collect_non_empty_notes(notes)?; + + Ok(GenesisBlockBuilder { + state: WithNotes { notes }, + }) + } + /// Add multiple genesis transfer output notes at once, transitioning to /// [`WithNotes`]. - /// - /// # Panics - /// - /// Panics if `notes` is empty. #[must_use] - pub fn add_notes( - self, - notes: impl IntoIterator>, - ) -> GenesisBlockBuilder { - let mut iter = notes.into_iter().peekable(); - assert!( - iter.peek().is_some(), - "add_notes called with empty iterator" - ); + pub fn add_notes(self, notes: [Note; N]) -> GenesisBlockBuilder { GenesisBlockBuilder { state: WithNotes { - notes: iter.map(Into::into).collect(), + notes: notes.into(), }, } } @@ -278,36 +332,28 @@ impl GenesisBlockBuilder { impl GenesisBlockBuilder { /// Append another genesis transfer output note. - #[must_use] - pub fn add_note(self, note: Note) -> Self { + pub fn try_add_note(self, note: Note) -> Result { let Self { state: WithNotes { mut notes }, } = self; - notes.push(note); - Self { + notes = push_note(notes, note)?; + Ok(Self { state: WithNotes { notes }, - } + }) } - /// Append multiple genesis transfer output notes at once. - /// - /// # Panics - /// - /// Panics if `notes` is empty. - #[must_use] - pub fn add_notes(self, notes: impl IntoIterator>) -> Self { - let mut iter = notes.into_iter().peekable(); - assert!( - iter.peek().is_some(), - "add_notes called with empty iterator" - ); + /// Try append multiple genesis transfer output notes at once. + pub fn try_add_notes( + self, + notes_to_add: impl IntoIterator>, + ) -> Result { let Self { state: WithNotes { mut notes }, } = self; - notes.extend(iter.map(Into::into)); - Self { + notes = extend_non_empty_notes(notes, notes_to_add)?; + Ok(Self { state: WithNotes { notes }, - } + }) } /// Set the genesis inscription, transitioning to @@ -384,34 +430,42 @@ impl GenesisBlockBuilder { } = self; GenesisBlockBuilder { state: WithNotesAndInscription { - notes: vec![note], + notes: [note].into(), inscription, }, } } - /// Add multiple genesis transfer output notes at once, transitioning to + /// Try add multiple genesis transfer output notes at once, transitioning to /// [`WithNotesAndInscription`]. - /// - /// # Panics - /// - /// Panics if `notes` is empty. - #[must_use] - pub fn add_notes( + pub fn try_add_notes( self, notes: impl IntoIterator>, + ) -> Result> { + let Self { + state: WithInscription { inscription }, + } = self; + Ok(GenesisBlockBuilder { + state: WithNotesAndInscription { + notes: collect_non_empty_notes(notes)?, + inscription, + }, + }) + } + + /// Add multiple genesis transfer output notes at once, transitioning to + /// [`WithNotesAndInscription`]. + #[must_use] + pub fn add_notes( + self, + notes: [Note; N], ) -> GenesisBlockBuilder { - let mut iter = notes.into_iter().peekable(); - assert!( - iter.peek().is_some(), - "add_notes called with empty iterator" - ); let Self { state: WithInscription { inscription }, } = self; GenesisBlockBuilder { state: WithNotesAndInscription { - notes: iter.map(Into::into).collect(), + notes: notes.into(), inscription, }, } @@ -484,34 +538,42 @@ impl GenesisBlockBuilder { } = self; GenesisBlockBuilder { state: WithNotesAndDeclarations { - notes: vec![note], + notes: [note].into(), sdp_declarations, }, } } - /// Add multiple genesis transfer output notes at once, transitioning to + /// Try add multiple genesis transfer output notes at once, transitioning to /// [`WithNotesAndDeclarations`]. - /// - /// # Panics - /// - /// Panics if `notes` is empty. - #[must_use] - pub fn add_notes( + pub fn try_add_notes( self, notes: impl IntoIterator>, + ) -> Result> { + let Self { + state: WithDeclarations { sdp_declarations }, + } = self; + Ok(GenesisBlockBuilder { + state: WithNotesAndDeclarations { + notes: collect_non_empty_notes(notes)?, + sdp_declarations, + }, + }) + } + + /// Add multiple genesis transfer output notes at once, transitioning to + /// [`WithNotesAndDeclarations`]. + #[must_use] + pub fn add_notes( + self, + notes: [Note; N], ) -> GenesisBlockBuilder { - let mut iter = notes.into_iter().peekable(); - assert!( - iter.peek().is_some(), - "add_notes called with empty iterator" - ); let Self { state: WithDeclarations { sdp_declarations }, } = self; GenesisBlockBuilder { state: WithNotesAndDeclarations { - notes: iter.map(Into::into).collect(), + notes: notes.into(), sdp_declarations, }, } @@ -581,8 +643,7 @@ impl GenesisBlockBuilder { impl GenesisBlockBuilder { /// Append another genesis transfer output note. - #[must_use] - pub fn add_note(self, note: Note) -> Self { + pub fn add_note(self, note: Note) -> Result { let Self { state: WithNotesAndInscription { @@ -590,24 +651,17 @@ impl GenesisBlockBuilder { inscription, }, } = self; - notes.push(note); - Self { + notes = push_note(notes, note)?; + Ok(Self { state: WithNotesAndInscription { notes, inscription }, - } + }) } /// Append multiple genesis transfer output notes at once. - /// - /// # Panics - /// - /// Panics if `notes` is empty. - #[must_use] - pub fn add_notes(self, notes: impl IntoIterator>) -> Self { - let mut iter = notes.into_iter().peekable(); - assert!( - iter.peek().is_some(), - "add_notes called with empty iterator" - ); + pub fn add_notes( + self, + notes_to_add: impl IntoIterator>, + ) -> Result { let Self { state: WithNotesAndInscription { @@ -615,10 +669,10 @@ impl GenesisBlockBuilder { inscription, }, } = self; - notes.extend(iter.map(Into::into)); - Self { + notes = extend_non_empty_notes(notes, notes_to_add)?; + Ok(Self { state: WithNotesAndInscription { notes, inscription }, - } + }) } /// Replace the current inscription. @@ -695,8 +749,7 @@ impl GenesisBlockBuilder { impl GenesisBlockBuilder { /// Append another genesis transfer output note. - #[must_use] - pub fn add_note(self, note: Note) -> Self { + pub fn add_note(self, note: Note) -> Result { let Self { state: WithNotesAndDeclarations { @@ -704,27 +757,20 @@ impl GenesisBlockBuilder { sdp_declarations, }, } = self; - notes.push(note); - Self { + notes = push_note(notes, note)?; + Ok(Self { state: WithNotesAndDeclarations { notes, sdp_declarations, }, - } + }) } /// Append multiple genesis transfer output notes at once. - /// - /// # Panics - /// - /// Panics if `notes` is empty. - #[must_use] - pub fn add_notes(self, notes: impl IntoIterator>) -> Self { - let mut iter = notes.into_iter().peekable(); - assert!( - iter.peek().is_some(), - "add_notes called with empty iterator" - ); + pub fn add_notes( + self, + notes_to_add: impl IntoIterator>, + ) -> Result { let Self { state: WithNotesAndDeclarations { @@ -732,13 +778,13 @@ impl GenesisBlockBuilder { sdp_declarations, }, } = self; - notes.extend(iter.map(Into::into)); - Self { + notes = extend_non_empty_notes(notes, notes_to_add)?; + Ok(Self { state: WithNotesAndDeclarations { notes, sdp_declarations, }, - } + }) } /// Set the genesis inscription, completing all three pieces and @@ -829,7 +875,7 @@ impl GenesisBlockBuilder { } = self; GenesisBlockBuilder { state: WithAll { - notes: vec![note], + notes: [note].into(), inscription, sdp_declarations, }, @@ -842,16 +888,10 @@ impl GenesisBlockBuilder { /// # Panics /// /// Panics if `notes` is empty. - #[must_use] pub fn add_notes( self, notes: impl IntoIterator>, - ) -> GenesisBlockBuilder { - let mut iter = notes.into_iter().peekable(); - assert!( - iter.peek().is_some(), - "add_notes called with empty iterator" - ); + ) -> Result> { let Self { state: WithInscriptionAndDeclarations { @@ -859,13 +899,13 @@ impl GenesisBlockBuilder { sdp_declarations, }, } = self; - GenesisBlockBuilder { + Ok(GenesisBlockBuilder { state: WithAll { - notes: iter.map(Into::into).collect(), + notes: collect_non_empty_notes(notes)?, inscription, sdp_declarations, }, - } + }) } /// Replace the current inscription. @@ -941,8 +981,7 @@ impl GenesisBlockBuilder { impl GenesisBlockBuilder { /// Append another genesis transfer output note. - #[must_use] - pub fn add_note(self, note: Note) -> Self { + pub fn add_note(self, note: Note) -> Result { let Self { state: WithAll { @@ -951,28 +990,21 @@ impl GenesisBlockBuilder { sdp_declarations, }, } = self; - notes.push(note); - Self { + notes = push_note(notes, note)?; + Ok(Self { state: WithAll { notes, inscription, sdp_declarations, }, - } + }) } /// Append multiple genesis transfer output notes at once. - /// - /// # Panics - /// - /// Panics if `notes` is empty. - #[must_use] - pub fn add_notes(self, notes: impl IntoIterator>) -> Self { - let mut iter = notes.into_iter().peekable(); - assert!( - iter.peek().is_some(), - "add_notes called with empty iterator" - ); + pub fn add_notes( + self, + notes_to_add: impl IntoIterator>, + ) -> Result { let Self { state: WithAll { @@ -981,14 +1013,14 @@ impl GenesisBlockBuilder { sdp_declarations, }, } = self; - notes.extend(iter.map(Into::into)); - Self { + notes = extend_non_empty_notes(notes, notes_to_add)?; + Ok(Self { state: WithAll { notes, inscription, sdp_declarations, }, - } + }) } /// Replace the current inscription. @@ -1203,7 +1235,7 @@ mod tests { let mut ops = vec![ Op::Transfer(TransferOp::new( Inputs::empty(), - Outputs::new(vec![make_note(1_000)]), + Outputs::new([make_note(1_000)]), )), Op::ChannelInscribe(valid_inscription()), ]; @@ -1337,9 +1369,7 @@ mod tests { #[test] fn multiple_notes_are_preserved() { let block = GenesisBlockBuilder::new() - .add_note(make_note(100)) - .add_note(make_note(200)) - .add_note(make_note(300)) + .add_notes([make_note(100), make_note(200), make_note(300)]) .set_inscription(valid_inscription()) .add_declaration(make_sdp_decl(0)) .build() @@ -1370,9 +1400,11 @@ mod tests { .add_note(make_note(10)) .add_declaration(make_sdp_decl(0)) .add_note(make_note(20)) + .unwrap() .set_inscription(valid_inscription()) .add_declaration(make_sdp_decl(1)) .add_note(make_note(30)) + .unwrap() .build() .unwrap(); @@ -1459,8 +1491,7 @@ mod tests { #[test] fn add_notes_and_add_declarations_interleaved_with_batch() { let block = GenesisBlockBuilder::new() - .add_note(make_note(1)) - .add_notes([make_note(2), make_note(3)]) + .add_notes([make_note(1), make_note(2), make_note(3)]) .set_inscription(valid_inscription()) .add_declaration(make_sdp_decl(0)) .add_declarations([make_sdp_decl(1), make_sdp_decl(2)]) @@ -1473,19 +1504,20 @@ mod tests { } #[test] - #[should_panic(expected = "add_notes called with empty iterator")] - fn add_notes_panics_on_empty_from_empty() { - drop(GenesisBlockBuilder::new().add_notes(std::iter::empty::())); + fn try_add_notes_errors_on_empty_from_empty() { + let err = GenesisBlockBuilder::new() + .try_add_notes(std::iter::empty::()) + .unwrap_err(); + assert!(matches!(err, Error::EmptyNotes)); } #[test] - #[should_panic(expected = "add_notes called with empty iterator")] - fn add_notes_panics_on_empty_from_with_notes() { - drop( - GenesisBlockBuilder::new() - .add_note(make_note(1)) - .add_notes(std::iter::empty::()), - ); + fn try_add_notes_errors_on_empty_from_with_notes() { + let err = GenesisBlockBuilder::new() + .add_note(make_note(1)) + .try_add_notes(std::iter::empty::()) + .unwrap_err(); + assert!(matches!(err, Error::EmptyNotes)); } #[test] diff --git a/core/src/mantle/channel.rs b/core/src/mantle/channel.rs index 7952e308b..9c28518fb 100644 --- a/core/src/mantle/channel.rs +++ b/core/src/mantle/channel.rs @@ -433,7 +433,7 @@ mod tests { let withdraw_op = ChannelWithdrawOp { channel_id, - outputs: Outputs::new(vec![Note { + outputs: Outputs::new([Note { value: 6, pk: ZkPublicKey::zero(), }]), @@ -465,7 +465,7 @@ mod tests { let withdraw_op = ChannelWithdrawOp { channel_id, - outputs: Outputs::new(vec![Note { + outputs: Outputs::new([Note { value: 6, pk: ZkPublicKey::zero(), }]), @@ -490,7 +490,7 @@ mod tests { let withdraw_op = ChannelWithdrawOp { channel_id, - outputs: Outputs::new(vec![Note { + outputs: Outputs::new([Note { value: 6, pk: ZkPublicKey::zero(), }]), diff --git a/core/src/mantle/encoding.rs b/core/src/mantle/encoding.rs index ed77feb63..fe02d5397 100644 --- a/core/src/mantle/encoding.rs +++ b/core/src/mantle/encoding.rs @@ -50,6 +50,16 @@ const LOCATOR_BYTES_SIZE_LIMIT: usize = 329usize; pub const MAX_OPS_PER_TX: usize = u8::MAX as usize; pub type Ops = UpperBoundedVec; type NomOps<'a> = NomBoundedVec<'a, Op, { Ops::MIN }, { Ops::MAX }, 1>; +const MAX_TRANSACTION_INPUTS: usize = u8::MAX as usize; +const MAX_TRANSACTION_OUTPUTS: usize = u8::MAX as usize; +pub type BoundedUtxos = UpperBoundedVec; +pub type BoundedInputs = UpperBoundedVec; +pub type NomInputs<'a> = + NomBoundedVec<'a, NoteId, { BoundedInputs::MIN }, { BoundedInputs::MAX }, 1>; + +pub type BoundedOutputs = UpperBoundedVec; +pub type NomOutputs<'a> = + NomBoundedVec<'a, Note, { BoundedOutputs::MIN }, { BoundedOutputs::MAX }, 1>; // ============================================================================== // Top-Level Transaction Decoders @@ -215,37 +225,16 @@ pub(crate) fn decode_leader_claim(input: &[u8]) -> IResult<&[u8], LeaderClaimOp> // Transfer Decoders // ============================================================================== -fn decode_note(input: &[u8]) -> IResult<&[u8], Note> { - // Note = Value ZkPublicKey - let (input, value) = decode_uint64(input)?; - let (input, pk) = decode_zk_public_key(input)?; - - Ok((input, Note::new(value, pk))) -} - fn decode_inputs(input: &[u8]) -> IResult<&[u8], Inputs> { - // Inputs = InputCount *NoteId - let (input, input_count) = decode_byte(input)?; + let (input, bounded_inputs) = NomInputs::decode(input)?; - let (input, note_ids) = - count(map(decode_field_element, NoteId), input_count as usize).parse(input)?; - Ok(( - input, - // TODO: This will go once all ops use the same `Inputs` type. - Inputs::new( - note_ids - .try_into() - .map_err(|_| nom::Err::Error(Error::new(input, ErrorKind::Fail)))?, - ), - )) + Ok((input, Inputs::new(bounded_inputs))) } fn decode_outputs(input: &[u8]) -> IResult<&[u8], Outputs> { - // Outputs = OutputCount *Note - let (input, output_count) = decode_byte(input)?; - let (input, notes) = count(decode_note, output_count as usize).parse(input)?; + let (input, bounded_outputs) = NomOutputs::decode(input)?; - Ok((input, Outputs::new(notes))) + Ok((input, Outputs::new(bounded_outputs))) } pub(crate) fn decode_transfer(input: &[u8]) -> IResult<&[u8], TransferOp> { @@ -333,7 +322,7 @@ fn decode_groth16(input: &[u8]) -> IResult<&[u8], CompressedGroth16Proof> { .parse(input) } -fn decode_zk_public_key(input: &[u8]) -> IResult<&[u8], ZkPublicKey> { +pub(crate) fn decode_zk_public_key(input: &[u8]) -> IResult<&[u8], ZkPublicKey> { // ZkPublicKey = FieldElement map(decode_field_element, ZkPublicKey::new).parse(input) } @@ -458,6 +447,7 @@ use lb_groth16::fr_to_bytes; use crate::{ mantle::{ + Utxo, ledger::{Inputs, Outputs}, ops::channel::{ChannelKeyIndex, withdraw::ChannelWithdrawOp}, tx::MantleTxGasContext, @@ -633,13 +623,7 @@ fn encode_note(note: &Note) -> Vec { bytes } -fn encode_inputs(inputs: &[NoteId]) -> Vec { - assert!( - u8::try_from(inputs.len()).is_ok(), - "Fatal error in 'encode_inputs' - {} inputs clipped to {}", - inputs.len(), - u8::MAX - ); +fn encode_inputs(inputs: &BoundedInputs) -> Vec { let mut bytes = Vec::new(); bytes.extend(encode_byte(inputs.len() as u8)); for input in inputs { @@ -648,14 +632,8 @@ fn encode_inputs(inputs: &[NoteId]) -> Vec { bytes } -fn encode_outputs(outputs: &[Note]) -> Vec { +fn encode_outputs(outputs: &BoundedOutputs) -> Vec { let mut bytes = Vec::new(); - assert!( - u8::try_from(outputs.len()).is_ok(), - "Fatal error in 'encode_outputs' - {} outputs clipped to {}", - outputs.len(), - u8::MAX - ); bytes.extend(encode_byte(outputs.len() as u8)); for output in outputs { bytes.extend(encode_note(output)); @@ -1065,7 +1043,7 @@ mod tests { let pk = ZkPublicKey::from(BigUint::from(42u64)); let note = Note::new(1000, pk); let note_id = NoteId(BigUint::from(123u64).into()); - let transfer_op = TransferOp::new(note_id.into(), Outputs::new(vec![note])); + let transfer_op = TransferOp::new(Inputs::new([note_id]), Outputs::new([note])); let original_tx = MantleTx(Ops::new_unchecked(vec![Op::Transfer(transfer_op)])); @@ -1191,7 +1169,7 @@ mod tests { let locator2: Multiaddr = "/ip6/::1/tcp/9090".parse().unwrap(); let locked_note_sk = ZkKey::from(BigUint::from(1u64)); - let locked_note = crate::mantle::Utxo { + let locked_note = Utxo { op_id: [1u8; 32], output_index: 12, note: Note { @@ -1390,8 +1368,8 @@ mod tests { let note_id3 = NoteId(BigUint::from(333u64).into()); let transfer_op = TransferOp::new( - [note_id1, note_id2, note_id3].into(), - Outputs::new(vec![note1, note2]), + Inputs::new([note_id1, note_id2, note_id3]), + Outputs::new([note1, note2]), ); let mantle_tx = MantleTx(Ops::new_unchecked(vec![Op::Transfer(transfer_op)])); @@ -1438,8 +1416,8 @@ mod tests { let locked_note_sk = ZkKey::from(BigUint::from(1u64)); let transfer_op = TransferOp { - inputs: NoteId(BigUint::from(777u64).into()).into(), - outputs: Outputs::new(vec![Note::new(5000, locked_note_sk.to_public_key())]), + inputs: Inputs::new([NoteId(BigUint::from(777u64).into())]), + outputs: Outputs::new([Note::new(5000, locked_note_sk.to_public_key())]), }; let locator: Multiaddr = "/dns4/example.com/tcp/443".parse().unwrap(); @@ -1584,7 +1562,7 @@ mod tests { let mantle_tx = MantleTx(Ops::new_unchecked(vec![Op::ChannelWithdraw( ChannelWithdrawOp { channel_id: ChannelId::from([0xAB; 32]), - outputs: Outputs::new(vec![note1, note2]), + outputs: Outputs::new([note1, note2]), withdraw_nonce: 0, }, )])); @@ -1881,30 +1859,6 @@ mod tests { ); } - #[test] - fn test_encode_reject_excessive_input_count() { - let note_id = NoteId(BigUint::from(111u64).into()); - let inputs = [note_id; u8::MAX as usize + 1]; - - // Should panic - let result = panic::catch_unwind(|| { - encode_inputs(&inputs); - }); - assert!(result.is_err(), "Should reject excessive output count"); - } - - #[test] - fn test_encode_reject_excessive_output_count() { - let note = Note::new(1000, ZkPublicKey::from(BigUint::from(42u64))); - let outputs = [note; u8::MAX as usize + 1]; - - // Should panic - let result = panic::catch_unwind(|| { - encode_outputs(&outputs); - }); - assert!(result.is_err(), "Should reject excessive output count"); - } - #[test] fn test_decode_reject_oversized_locator() { // Create a malicious input with oversized locator @@ -1992,6 +1946,7 @@ mod tests { fn test_encode_decode_max_inputs() { let note_id = NoteId(BigUint::from(111u64).into()); let inputs = [note_id; u8::MAX as usize]; + let inputs = BoundedInputs::from(inputs); // Encode should succeed let encoded = encode_inputs(&inputs); @@ -2015,6 +1970,7 @@ mod tests { fn test_encode_decode_max_outputs() { let note = Note::new(1000, ZkPublicKey::from(BigUint::from(42u64))); let outputs = [note; u8::MAX as usize]; + let outputs = BoundedOutputs::from(outputs); // Encode should succeed let encoded = encode_outputs(&outputs); diff --git a/core/src/mantle/genesis_tx.rs b/core/src/mantle/genesis_tx.rs index 36fa70623..e5a992471 100644 --- a/core/src/mantle/genesis_tx.rs +++ b/core/src/mantle/genesis_tx.rs @@ -343,8 +343,7 @@ mod tests { // Helper function to create a basic signed transaction // Genesis transactions don't need verified proofs for Blob/Inscription ops fn create_tx(mut ops: Vec, mut ops_proofs: Vec) -> SignedMantleTx { - let transfer_op = - TransferOp::new(Inputs::empty(), Outputs::new(vec![create_test_note(1000)])); + let transfer_op = TransferOp::new(Inputs::empty(), Outputs::new([create_test_note(1000)])); let mut new_ops = vec![Op::Transfer(transfer_op)]; new_ops.append(&mut ops); let mantle_tx = MantleTx(Ops::new_unchecked(new_ops)); diff --git a/core/src/mantle/ledger.rs b/core/src/mantle/ledger.rs index 164aa0ac1..b92b50d4d 100644 --- a/core/src/mantle/ledger.rs +++ b/core/src/mantle/ledger.rs @@ -1,11 +1,11 @@ -use std::{collections::HashSet, slice, sync::LazyLock}; +use std::{collections::HashSet, sync::LazyLock}; use ark_ff::PrimeField as _; use bytes::Bytes; use lb_groth16::{Fr, fr_from_bytes, serde::serde_fr}; use lb_key_management_system_keys::keys::ZkPublicKey; use lb_poseidon2::Digest as _; -use lb_utils::bounded_vec::{BoundedError, UpperBoundedVec}; +use lb_utils::bounded_vec::BoundedError; use lb_utxotree::UtxoTree; use num_bigint::BigUint; use serde::{Deserialize, Serialize}; @@ -15,7 +15,8 @@ use crate::{ crypto::{Hash, ZkHasher}, events::Events, mantle::{ - nom::{NomBoundedVec, NomDecode, NomEncode}, + encoding::{BoundedInputs, BoundedOutputs, NomInputs, decode_uint64, decode_zk_public_key}, + nom::{NomDecode, NomEncode}, ops::OpId, }, sdp::{Declaration, DeclarationId, locked_notes::LockedNotes}, @@ -48,6 +49,8 @@ pub enum InputsError { DoubleSpend, #[error("Sum of input values overflows")] InputsOverflow, + #[error(transparent)] + BoundedError(#[from] BoundedError), } #[derive(Clone, Debug, Error, Eq, PartialEq)] @@ -56,6 +59,8 @@ pub enum OutputsError { ZeroValueNote, #[error("Sum of output values overflows")] OutputsOverflow, + #[error(transparent)] + BoundedError(#[from] BoundedError), } #[derive(Clone, Debug, Error, Eq, PartialEq)] @@ -67,12 +72,23 @@ pub enum LedgerError { } #[derive(Clone, Eq, Debug, PartialEq, Serialize, Deserialize)] -pub struct Outputs(Vec); +pub struct Outputs(BoundedOutputs); impl Outputs { + pub fn try_new( + notes: impl TryInto, + ) -> Result { + notes.try_into().map(Self).map_err(OutputsError::from) + } + #[must_use] - pub const fn new(notes: Vec) -> Self { - Self(notes) + pub fn new(notes: impl Into) -> Self { + Self(notes.into()) + } + + #[must_use] + pub fn empty() -> Self { + Self(BoundedOutputs::default()) } pub fn utxos(&self, op: &O) -> impl Iterator { @@ -128,66 +144,54 @@ impl Outputs { self.0.is_empty() } - pub fn iter(&self) -> slice::Iter<'_, Note> { + pub fn iter(&self) -> impl Iterator { <&Self as IntoIterator>::into_iter(self) } } -impl AsRef> for Outputs { - fn as_ref(&self) -> &Vec { +impl AsRef for Outputs { + fn as_ref(&self) -> &BoundedOutputs { &self.0 } } -impl AsMut> for Outputs { - fn as_mut(&mut self) -> &mut Vec { +impl AsMut for Outputs { + fn as_mut(&mut self) -> &mut BoundedOutputs { &mut self.0 } } impl<'output> IntoIterator for &'output Outputs { - type Item = as IntoIterator>::Item; - type IntoIter = slice::Iter<'output, Note>; + type Item = <&'output BoundedOutputs as IntoIterator>::Item; + type IntoIter = <&'output BoundedOutputs as IntoIterator>::IntoIter; fn into_iter(self) -> Self::IntoIter { - self.0.iter() + (&self.0).into_iter() } } -pub type InnerInputs = UpperBoundedVec; -type NomInputs<'a> = NomBoundedVec<'a, NoteId, { InnerInputs::MIN }, { InnerInputs::MAX }, 1>; - #[derive(Clone, Eq, Debug, PartialEq, Hash, Serialize, Deserialize)] -pub struct Inputs(InnerInputs); - -impl AsRef<[NoteId]> for Inputs { - fn as_ref(&self) -> &[NoteId] { - &self.0 - } -} - -impl From for Inputs -where - I: Into, -{ - fn from(value: I) -> Self { - Self(value.into()) - } -} +pub struct Inputs(BoundedInputs); impl Inputs { #[must_use] - pub const fn new(inputs: InnerInputs) -> Self { - Self(inputs) + pub fn new(note_ids: impl Into) -> Self { + Self(note_ids.into()) + } + + pub fn try_new( + note_ids: impl TryInto, + ) -> Result { + note_ids.try_into().map(Self).map_err(InputsError::from) } #[must_use] - pub const fn empty() -> Self { - Self(InnerInputs::new_unchecked(vec![])) + pub fn empty() -> Self { + Self(BoundedInputs::default()) } #[must_use] - pub fn into_inner(self) -> InnerInputs { + pub fn into_inner(self) -> BoundedInputs { self.0 } @@ -206,7 +210,7 @@ impl Inputs { } pub fn iter(&self) -> impl Iterator { - self.0.iter() + <&Self as IntoIterator>::into_iter(self) } pub fn validate(&self, locked_notes: &LockedNotes, utxos: &Utxos) -> Result<(), InputsError> { @@ -264,6 +268,41 @@ impl Inputs { } } +impl AsRef for Inputs { + fn as_ref(&self) -> &BoundedInputs { + &self.0 + } +} + +impl AsRef<[NoteId]> for Inputs { + fn as_ref(&self) -> &[NoteId] { + &self.0 + } +} + +impl From for Inputs +where + I: Into, +{ + fn from(value: I) -> Self { + Self(value.into()) + } +} + +impl AsMut for Inputs { + fn as_mut(&mut self) -> &mut BoundedInputs { + &mut self.0 + } +} +impl<'input> IntoIterator for &'input Inputs { + type Item = <&'input BoundedInputs as IntoIterator>::Item; + type IntoIter = <&'input BoundedInputs as IntoIterator>::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + (&self.0).into_iter() + } +} + impl NomEncode for Inputs { fn encode(&self) -> Vec { NomInputs::from(&self.0).encode() @@ -338,6 +377,27 @@ impl Note { } } +impl NomEncode for Note { + fn encode(&self) -> Vec { + let mut bytes = Vec::new(); + bytes.extend(crate::mantle::encoding::encode_uint64(self.value)); + bytes.extend(crate::mantle::encoding::encode_field_element( + self.pk.as_fr(), + )); + bytes + } +} + +impl NomDecode for Note { + type Output = Self; + + fn decode(bytes: &[u8]) -> nom::IResult<&[u8], Self::Output> { + let (bytes, value) = decode_uint64(bytes)?; + let (bytes, pk) = decode_zk_public_key(bytes)?; + Ok((bytes, Self::new(value, pk))) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct Utxo { pub op_id: Hash, diff --git a/core/src/mantle/ops/transfer.rs b/core/src/mantle/ops/transfer.rs index 35b7c35c6..10e0e4d59 100644 --- a/core/src/mantle/ops/transfer.rs +++ b/core/src/mantle/ops/transfer.rs @@ -116,8 +116,8 @@ mod test { let pk1 = ZkPublicKey::from(Fr::from(BigUint::from(1u8))); let pk2 = ZkPublicKey::from(Fr::from(BigUint::from(2u8))); let transfer = TransferOp { - inputs: NoteId(BigUint::from(0u8).into()).into(), - outputs: Outputs::new(vec![ + inputs: Inputs::new([NoteId(BigUint::from(0u8).into())]), + outputs: Outputs::new([ Note::new(100, pk0), Note::new(200, pk1), Note::new(300, pk2), diff --git a/core/src/mantle/tx.rs b/core/src/mantle/tx.rs index 7b66f4282..8c36d0b40 100644 --- a/core/src/mantle/tx.rs +++ b/core/src/mantle/tx.rs @@ -734,7 +734,7 @@ mod tests { }; let mantle_tx = create_test_mantle_tx(vec![Op::ChannelWithdraw(ChannelWithdrawOp { channel_id, - outputs: Outputs::new(vec![withdraw_note]), + outputs: Outputs::new([withdraw_note]), withdraw_nonce: 0, })]); let tx_hash = mantle_tx.hash(); diff --git a/core/src/mantle/tx_builder.rs b/core/src/mantle/tx_builder.rs index 644a03072..4f5eb5278 100644 --- a/core/src/mantle/tx_builder.rs +++ b/core/src/mantle/tx_builder.rs @@ -1,12 +1,14 @@ use std::{cmp::Ordering, collections::HashMap}; use lb_key_management_system_keys::keys::ZkPublicKey; +use lb_utils::bounded_vec::BoundedError; +use thiserror::Error; use super::{GasCalculator as _, GasConstants, MantleTx, Note, Op, Utxo}; use crate::{ mantle::{ NoteId, - encoding::Ops, + encoding::BoundedUtxos, gas::{GasCost, GasOverflow}, ledger::{Inputs, Outputs}, ops::{channel::withdraw::ChannelWithdrawOp, transfer::TransferOp}, @@ -15,10 +17,44 @@ use crate::{ proofs::channel_multi_sig_proof::ChannelMultiSigProof, }; +#[derive(Debug, Error)] +pub enum TxBuilderError { + #[error("Too many operations in transaction: attempted {actual}, max {max}")] + TooManyOps { actual: usize, max: usize }, + #[error("Too many ledger inputs in transfer: attempted {actual}, max {max}")] + TooManyInputs { actual: usize, max: usize }, + #[error("Too many ledger outputs in transfer: attempted {actual}, max {max}")] + TooManyOutputs { actual: usize, max: usize }, + #[error("Gas computation overflow: {0}")] + GasOverflow(#[from] GasOverflow), +} + +#[derive(Debug, Clone, Copy)] +enum TooManyTag { + Ops, + Inputs, + Outputs, +} + +impl From<(BoundedError, TooManyTag)> for TxBuilderError { + fn from((err, tag): (BoundedError, TooManyTag)) -> Self { + let (actual, max) = match err { + BoundedError::TooLong { actual, max } => (actual, max), + BoundedError::EmptyInput => (0, 0), + }; + + match tag { + TooManyTag::Ops => Self::TooManyOps { actual, max }, + TooManyTag::Inputs => Self::TooManyInputs { actual, max }, + TooManyTag::Outputs => Self::TooManyOutputs { actual, max }, + } + } +} + #[derive(Debug, Clone)] pub struct MantleTxBuilder { mantle_tx: MantleTx, - ledger_inputs: Vec, + ledger_inputs: BoundedUtxos, pending_transfer: TransferOp, // Maps a Proof to its Op by the Op Index channel_multi_sig_proofs: HashMap, @@ -30,9 +66,9 @@ impl MantleTxBuilder { #[must_use] pub fn new(context: MantleTxContext) -> Self { Self { - mantle_tx: MantleTx(Ops::empty()), - ledger_inputs: vec![], - pending_transfer: TransferOp::new(Inputs::empty(), Outputs::new(vec![])), + mantle_tx: MantleTx([].into()), + ledger_inputs: BoundedUtxos::default(), + pending_transfer: TransferOp::new(Inputs::empty(), Outputs::empty()), channel_multi_sig_proofs: HashMap::new(), context, } @@ -43,64 +79,80 @@ impl MantleTxBuilder { self.context.gas_context.get_gas_prices() } - #[must_use] - pub fn push_op(self, op: Op) -> Self { + pub fn push_op(self, op: Op) -> Result { self.extend_ops([op]) } // TODO: Change this to a `Result` if trying to push too many ops in the genesis // block. - #[must_use] - pub fn extend_ops(mut self, ops: impl IntoIterator) -> Self { + pub fn extend_ops(mut self, ops: impl IntoIterator) -> Result { for op in ops { - self.mantle_tx.0.try_push(op).expect("Too many ops."); + self.mantle_tx + .0 + .try_push(op) + .map_err(|err| TxBuilderError::from((err, TooManyTag::Ops)))?; } - self + Ok(self) } - #[must_use] - pub fn push_channel_withdraw(self, op: ChannelWithdrawOp, proof: ChannelMultiSigProof) -> Self { - let mut builder = self.push_op(Op::ChannelWithdraw(op)); + pub fn push_channel_withdraw( + self, + op: ChannelWithdrawOp, + proof: ChannelMultiSigProof, + ) -> Result { + let mut builder = self.push_op(Op::ChannelWithdraw(op))?; let index = builder.mantle_tx.ops().len() - 1; builder.channel_multi_sig_proofs.insert(index, proof); - builder + Ok(builder) } - #[must_use] - pub fn add_ledger_input(self, utxo: Utxo) -> Self { + pub fn add_ledger_input(self, utxo: Utxo) -> Result { self.extend_ledger_inputs([utxo]) } - #[must_use] - pub fn extend_ledger_inputs(mut self, utxos: impl IntoIterator) -> Self { + pub fn extend_ledger_inputs( + mut self, + utxos: impl IntoIterator, + ) -> Result { for utxo in utxos { + assert_eq!(self.pending_transfer.inputs.len(), self.ledger_inputs.len()); self.pending_transfer .inputs + .as_mut() .try_push(utxo.id()) - .expect("Too many inputs in transfer op."); - self.ledger_inputs.push(utxo); + .map_err(|err| TxBuilderError::from((err, TooManyTag::Inputs)))?; + self.ledger_inputs + .try_push(utxo) + .map_err(|err| TxBuilderError::from((err, TooManyTag::Inputs)))?; } - self + Ok(self) } - #[must_use] - pub fn add_ledger_output(self, note: Note) -> Self { + pub fn add_ledger_output(self, note: Note) -> Result { self.extend_ledger_outputs([note]) } - #[must_use] - pub fn extend_ledger_outputs(mut self, notes: impl IntoIterator) -> Self { - self.pending_transfer.outputs.as_mut().extend(notes); - self + pub fn extend_ledger_outputs( + mut self, + notes: impl IntoIterator, + ) -> Result { + for note in notes { + self.pending_transfer + .outputs + .as_mut() + .try_push(note) + .map_err(|err| TxBuilderError::from((err, TooManyTag::Outputs)))?; + } + Ok(self) } pub fn return_change( self, change_pk: ZkPublicKey, - ) -> Result, GasOverflow> { + ) -> Result, TxBuilderError> { // Calculate the funding delta with a dummy change note to account for // the gas cost increase from adding the output - let delta_with_change = self.with_dummy_change_note().funding_delta::()?; + let delta_with_change = self.with_dummy_change_note()?.funding_delta::()?; match delta_with_change.cmp(&0) { Ordering::Less | Ordering::Equal => { @@ -121,18 +173,17 @@ impl MantleTxBuilder { let tx_with_change = self.add_ledger_output(Note { value: change, pk: change_pk, - }); + })?; // Now the net balance should exactly equal the gas cost. - assert_eq!(tx_with_change.funding_delta::().unwrap(), 0); + assert_eq!(tx_with_change.funding_delta::()?, 0); Ok(Some(tx_with_change)) } } } - #[must_use] - pub fn with_dummy_change_note(&self) -> Self { + pub fn with_dummy_change_note(&self) -> Result { self.clone().add_ledger_output(Note { value: 0, pk: ZkPublicKey::zero(), @@ -157,12 +208,12 @@ impl MantleTxBuilder { in_sum - out_sum } - pub fn gas_cost(&self) -> Result { - let build = self.clone().build(); - build.total_gas_cost::(&self.context.gas_context) + pub fn gas_cost(&self) -> Result { + let build = self.clone().build()?; + Ok(build.total_gas_cost::(&self.context.gas_context)?) } - pub fn funding_delta(&self) -> Result { + pub fn funding_delta(&self) -> Result { Ok(self.net_balance() - i128::from(self.gas_cost::()?.into_inner())) } @@ -201,13 +252,12 @@ impl MantleTxBuilder { // TODO: Change this to a `Result` if genesis tx already contains max number of // ops. - #[must_use] - pub fn build(mut self) -> MantleTx { + pub fn build(mut self) -> Result { self.mantle_tx .0 .try_push(Op::Transfer(self.pending_transfer)) - .expect("Failed to push transfer op. Too many ops defined."); - self.mantle_tx + .map_err(|err| TxBuilderError::from((err, TooManyTag::Ops)))?; + Ok(self.mantle_tx) } } @@ -249,7 +299,9 @@ mod tests { gas_context: MantleTxGasContext::default(), leader_reward_amount: 30, }; - let builder = MantleTxBuilder::new(context).push_op(Op::ChannelInscribe(op)); + let builder = MantleTxBuilder::new(context) + .push_op(Op::ChannelInscribe(op)) + .unwrap(); // Check that the tx is already balanced because of zero gas price assert_eq!(builder.net_balance(), 0); @@ -261,7 +313,7 @@ mod tests { // Build an operation let op = DepositOp { channel_id: [0; 32].into(), - inputs: NoteId(Fr::ZERO).into(), + inputs: Inputs::new([NoteId(Fr::ZERO)]), metadata: b"Mint 1 to Alice in Zone".into(), }; @@ -270,7 +322,9 @@ mod tests { gas_context: MantleTxGasContext::default(), leader_reward_amount: 30, }; - let builder = MantleTxBuilder::new(context).push_op(Op::ChannelDeposit(op)); + let builder = MantleTxBuilder::new(context) + .push_op(Op::ChannelDeposit(op)) + .unwrap(); // Check that the tx is already balanced because of zero gas price assert_eq!(builder.net_balance(), 0); @@ -286,7 +340,7 @@ mod tests { }; let op = ChannelWithdrawOp { channel_id: [0; 32].into(), - outputs: Outputs::new(vec![withdraw_note]), + outputs: Outputs::new([withdraw_note]), withdraw_nonce: 0, }; @@ -299,7 +353,9 @@ mod tests { ), leader_reward_amount: 30, }; - let builder = MantleTxBuilder::new(context).push_op(Op::ChannelWithdraw(op)); + let builder = MantleTxBuilder::new(context) + .push_op(Op::ChannelWithdraw(op)) + .unwrap(); // Check that the tx is already balanced because of zero gas price assert_eq!(builder.net_balance(), 0); @@ -320,7 +376,9 @@ mod tests { gas_context: MantleTxGasContext::default(), leader_reward_amount: 30, }; - let builder = MantleTxBuilder::new(context).push_op(Op::LeaderClaim(op)); + let builder = MantleTxBuilder::new(context) + .push_op(Op::LeaderClaim(op)) + .unwrap(); // Check that the tx is already balanced because of zero gas price assert_eq!(builder.net_balance(), 0); @@ -336,7 +394,9 @@ mod tests { }; let builder = MantleTxBuilder::new(context) .add_ledger_output(Note::new(40, ZkPublicKey::zero())) + .unwrap() .add_ledger_input(Utxo::new([0u8; 32], 0, Note::new(50, ZkPublicKey::zero()))); + let builder = builder.unwrap(); // Check that the balance is 10 (= 50 - 40) assert_eq!(builder.net_balance(), 10); @@ -382,22 +442,27 @@ mod tests { parent: [1; 32].into(), signer: Ed25519Key::from_bytes(&[0; 32]).public_key(), })) + .unwrap() .push_op(Op::ChannelDeposit(DepositOp { channel_id, - inputs: NoteId(Fr::ZERO).into(), + inputs: Inputs::new([NoteId(Fr::ZERO)]), metadata: b"Mint 10 to Alice in Zone".into(), })) + .unwrap() .push_op(Op::ChannelWithdraw(ChannelWithdrawOp { channel_id, - outputs: Outputs::new(vec![withdraw_note]), + outputs: Outputs::new([withdraw_note]), withdraw_nonce: 0, })) + .unwrap() .push_op(Op::LeaderClaim(LeaderClaimOp { rewards_root: Fr::ZERO.into(), voucher_nullifier: Fr::ZERO.into(), pk: ZkPublicKey::zero(), })) - .add_ledger_output(Note::new(40, ZkPublicKey::zero())); + .unwrap() + .add_ledger_output(Note::new(40, ZkPublicKey::zero())) + .unwrap(); // Check the balance before funding tx assert_eq!(builder.net_balance(), -40); @@ -407,8 +472,9 @@ mod tests { ); // Fund tx - let builder = - builder.add_ledger_input(Utxo::new([0u8; 32], 0, Note::new(40, ZkPublicKey::zero()))); + let builder = builder + .add_ledger_input(Utxo::new([0u8; 32], 0, Note::new(40, ZkPublicKey::zero()))) + .unwrap(); // Check the tx is balanced assert_eq!(builder.net_balance(), 0); @@ -433,9 +499,10 @@ mod tests { let builder = MantleTxBuilder::new(context) .push_op(Op::ChannelDeposit(DepositOp { channel_id: [0; 32].into(), - inputs: deposit_input.into(), + inputs: Inputs::new([deposit_input]), metadata: Metadata::empty(), })) + .unwrap() .push_op(Op::SDPDeclare(SDPDeclareOp { service_type: ServiceType::BlendNetwork, locators: "/ip4/1.1.1.1/udp/0".parse::().unwrap().into(), @@ -443,12 +510,15 @@ mod tests { zk_id: ZkPublicKey::zero(), locked_note_id: declare_locked, })) + .unwrap() .push_op(Op::SDPWithdraw(SDPWithdrawOp { declaration_id: DeclarationId([0; 32]), locked_note_id: withdraw_locked, nonce: 1, })) - .add_ledger_input(transfer_input); + .unwrap() + .add_ledger_input(transfer_input) + .unwrap(); let consumed_or_locked: Vec<_> = builder.consumed_or_locked_notes().collect(); assert!( diff --git a/core/src/utils/merkle.rs b/core/src/utils/merkle.rs index 2bd674213..12ace08aa 100644 --- a/core/src/utils/merkle.rs +++ b/core/src/utils/merkle.rs @@ -44,7 +44,7 @@ mod tests { MantleTx( [Op::Transfer(TransferOp::new( Inputs::empty(), - Outputs::new(vec![Note { + Outputs::new([Note { value: seed.into(), pk: ZkPublicKey::zero(), }]), diff --git a/ledger/src/cryptarchia/mod.rs b/ledger/src/cryptarchia/mod.rs index 6b891bb5a..82480e1bc 100644 --- a/ledger/src/cryptarchia/mod.rs +++ b/ledger/src/cryptarchia/mod.rs @@ -542,7 +542,13 @@ impl LedgerState { ) -> Result> { let transfer_op = tx.genesis_transfer(); if !transfer_op.inputs.is_empty() { - return Err(LedgerError::InputInGenesis(transfer_op.inputs.as_ref()[0])); + let first_input = transfer_op + .inputs + .iter() + .next() + .copied() + .expect("is not empty"); + return Err(LedgerError::InputInGenesis(first_input)); } Ok(Self::from_utxos( @@ -1268,8 +1274,8 @@ pub mod tests { .collect::>(); let inputs = inputs.iter().map(|(_, utxo)| utxo.id()).collect::>(); let transfer_op = TransferOp::new( - Inputs::new(inputs.try_into().expect("Too many inputs in transfer op.")), - Outputs::new(outputs), + Inputs::try_new(inputs).expect("Invalid inputs size"), + Outputs::try_new(outputs).expect("Invalid outputs size"), ); let mantle_tx = MantleTx([Op::Transfer(transfer_op.clone())].into()); let transfer_sig = ZkKey::multi_sign(&sks, &mantle_tx.hash().to_fr()).unwrap(); diff --git a/ledger/src/lib.rs b/ledger/src/lib.rs index 4dd0a3baf..6fbe49a6e 100644 --- a/ledger/src/lib.rs +++ b/ledger/src/lib.rs @@ -763,8 +763,11 @@ mod tests { type HeaderId = [u8; 32]; - fn create_tx(inputs: Inputs, outputs: Vec, sks: &[ZkKey]) -> SignedMantleTx { - let transfer_op = TransferOp::new(inputs, Outputs::new(outputs)); + fn create_tx(inputs: Vec, outputs: Vec, sks: &[ZkKey]) -> SignedMantleTx { + let transfer_op = TransferOp::new( + Inputs::try_new(inputs).expect("Invalid inputs size"), + Outputs::try_new(outputs).expect("Invalid outputs size"), + ); let mantle_tx = MantleTx([Op::Transfer(transfer_op)].into()); SignedMantleTx { ops_proofs: vec![OpProof::ZkSig( @@ -873,7 +876,7 @@ mod tests { let sk = ZkKey::from(BigUint::from(0u8)); // determine fees let tx = create_tx( - [utxo.id()].into(), + vec![utxo.id()], vec![output_note], std::slice::from_ref(&sk), ); @@ -881,7 +884,7 @@ mod tests { AuthenticatedMantleTx::total_gas_cost::(&tx, GasPrices::default()) .unwrap(); output_note.value = utxo.note.value - fees.into_inner(); - let tx = create_tx([utxo.id()].into(), vec![output_note], &[sk]); + let tx = create_tx(vec![utxo.id()], vec![output_note], &[sk]); // Create a dummy proof (using same structure as in cryptarchia tests) @@ -1025,7 +1028,7 @@ mod tests { // Submit a deposit operation let deposit = DepositOp { channel_id, - inputs: [utxo.id()].into(), + inputs: Inputs::new([utxo.id()]), metadata: [5, 6, 7, 8].into(), }; let ops = vec![Op::ChannelDeposit(deposit.clone())]; @@ -1085,7 +1088,7 @@ mod tests { // Deposit some funds into the channel let deposit = DepositOp { channel_id, - inputs: [utxo.id()].into(), + inputs: Inputs::new([utxo.id()]), metadata: [5, 6, 7, 8].into(), }; let deposit_ops = vec![Op::ChannelDeposit(deposit)]; @@ -1117,7 +1120,7 @@ mod tests { }; let withdraw = ChannelWithdrawOp { channel_id, - outputs: Outputs::new(vec![withdraw_note]), + outputs: Outputs::new([withdraw_note]), withdraw_nonce: 0, }; let withdraw_tx = MantleTx([Op::ChannelWithdraw(withdraw.clone())].into()); @@ -1176,7 +1179,7 @@ mod tests { // Deposit some funds into the channel let deposit = DepositOp { channel_id, - inputs: [utxo.id()].into(), + inputs: Inputs::new([utxo.id()]), metadata: Metadata::empty(), }; let deposit_ops = vec![Op::ChannelDeposit(deposit)]; @@ -1204,7 +1207,7 @@ mod tests { }; let withdraw = ChannelWithdrawOp { channel_id, - outputs: Outputs::new(vec![withdraw_note]), + outputs: Outputs::new([withdraw_note]), withdraw_nonce: 0, }; let wrong_key = Ed25519Key::from_bytes(&[42; 32]); @@ -1495,7 +1498,7 @@ mod tests { let mut output_note = Note::new(1, ZkPublicKey::new(BigUint::from(0u8).into())); let sk = ZkKey::from(BigUint::from(0u8)); let tx = create_tx( - [utxo.id()].into(), + vec![utxo.id()], vec![output_note], std::slice::from_ref(&sk), ); @@ -1506,7 +1509,7 @@ mod tests { ) .unwrap(); output_note.value = utxo.note.value - fees.into_inner(); - let tx = create_tx([utxo.id()].into(), vec![output_note], &[sk]); + let tx = create_tx(vec![utxo.id()], vec![output_note], &[sk]); let result = ledger .clone() @@ -1534,7 +1537,7 @@ mod tests { let mut output_note = Note::new(1, ZkPublicKey::new(BigUint::from(0u8).into())); let sk = ZkKey::from(BigUint::from(0u8)); let tx = create_tx( - [utxo.id()].into(), + vec![utxo.id()], vec![output_note], std::slice::from_ref(&sk), ); @@ -1548,7 +1551,7 @@ mod tests { .unwrap(); output_note.value = utxo.note.value - fees.into_inner(); let tx = create_tx( - [utxo.id()].into(), + vec![utxo.id()], vec![output_note], std::slice::from_ref(&sk), ); @@ -1564,7 +1567,7 @@ mod tests { // storage output_note.value = utxo.note.value - fees.into_inner() - 1000; let tx = create_tx( - [utxo.id()].into(), + vec![utxo.id()], vec![output_note], std::slice::from_ref(&sk), ); diff --git a/nodes/node/binary/src/api/handlers.rs b/nodes/node/binary/src/api/handlers.rs index c0330a413..1e96e7b0b 100644 --- a/nodes/node/binary/src/api/handlers.rs +++ b/nodes/node/binary/src/api/handlers.rs @@ -894,7 +894,9 @@ where ); let tx_context = wallet.get_tx_context(None).await?; - let tx_builder = MantleTxBuilder::new(tx_context).push_op(Op::ChannelDeposit(req.deposit)); + let tx_builder = MantleTxBuilder::new(tx_context) + .push_op(Op::ChannelDeposit(req.deposit)) + .map_err(|e| overwatch::DynError::from(e.to_string()))?; let lb_wallet_service::TipResponse { tip, response: funded_tx_builder, diff --git a/nodes/node/binary/src/generic_services/sdp/wallet.rs b/nodes/node/binary/src/generic_services/sdp/wallet.rs index 1a71e71ed..bf9710ebe 100644 --- a/nodes/node/binary/src/generic_services/sdp/wallet.rs +++ b/nodes/node/binary/src/generic_services/sdp/wallet.rs @@ -36,7 +36,7 @@ where declaration: DeclarationMessage, config: &SdpWalletConfig, ) -> Result { - tx_builder = tx_builder.push_op(Op::SDPDeclare(declaration)); + tx_builder = tx_builder.push_op(Op::SDPDeclare(declaration))?; let funded = self .api @@ -69,7 +69,7 @@ where withdraw: WithdrawMessage, config: &SdpWalletConfig, ) -> Result { - tx_builder = tx_builder.push_op(Op::SDPWithdraw(withdraw)); + tx_builder = tx_builder.push_op(Op::SDPWithdraw(withdraw))?; let funded = self .api @@ -102,7 +102,7 @@ where active: ActiveMessage, config: &SdpWalletConfig, ) -> Result { - tx_builder = tx_builder.push_op(Op::SDPActive(active)); + tx_builder = tx_builder.push_op(Op::SDPActive(active))?; let funded = self .api diff --git a/services/chain/chain-leader/src/leadership.rs b/services/chain/chain-leader/src/leadership.rs index 76a296d3a..02597c9d2 100644 --- a/services/chain/chain-leader/src/leadership.rs +++ b/services/chain/chain-leader/src/leadership.rs @@ -403,7 +403,7 @@ mod pol_tests { let pk = sk.to_public_key(); // Create a UTXO - let transfer = TransferOp::new(Inputs::empty(), Outputs::new(vec![Note::new(1000u64, pk)])); + let transfer = TransferOp::new(Inputs::empty(), Outputs::new([Note::new(1000u64, pk)])); let utxo = transfer.outputs.utxo_by_index(0, &transfer).unwrap(); // Create aged/latest UTXO trees diff --git a/services/chain/chain-leader/src/wallet.rs b/services/chain/chain-leader/src/wallet.rs index 539703cd7..fc081bdba 100644 --- a/services/chain/chain-leader/src/wallet.rs +++ b/services/chain/chain-leader/src/wallet.rs @@ -6,7 +6,7 @@ use lb_core::{ Note, Op, SignedMantleTx, Value, gas::{GasCost, GasOverflow, MainnetGasConstants}, ops::leader_claim::LeaderClaimOp, - tx_builder::MantleTxBuilder, + tx_builder::{MantleTxBuilder, TxBuilderError}, }, }; use lb_key_management_system_service::keys::ZkPublicKey; @@ -41,7 +41,9 @@ where .map_err(|error| LeaderWalletError::WalletApi(Box::new(error)))?; let tx_builder = MantleTxBuilder::new(tx_context) .push_op(Op::LeaderClaim(op)) - .add_ledger_output(Note::new(reward_amount, config.funding_pk)); + .map_err(LeaderWalletError::TxBuilder)? + .add_ledger_output(Note::new(reward_amount, config.funding_pk)) + .map_err(LeaderWalletError::TxBuilder)?; let funded_tx_builder = wallet .fund_tx( Some(tip), @@ -83,4 +85,6 @@ pub enum LeaderWalletError { TxFeeExceedsMaxFee { max_fee: GasCost, tx_fee: GasCost }, #[error(transparent)] GasOverflow(#[from] GasOverflow), + #[error(transparent)] + TxBuilder(#[from] TxBuilderError), } diff --git a/services/sdp/src/wallet.rs b/services/sdp/src/wallet.rs index c73fb3be0..2cc9b1dd5 100644 --- a/services/sdp/src/wallet.rs +++ b/services/sdp/src/wallet.rs @@ -2,7 +2,7 @@ use lb_core::{ mantle::{ SignedMantleTx, gas::{GasCost, GasOverflow}, - tx_builder::MantleTxBuilder, + tx_builder::{MantleTxBuilder, TxBuilderError}, }, sdp::{ActiveMessage, DeclarationMessage, WithdrawMessage}, }; @@ -20,6 +20,8 @@ pub enum SdpWalletError { TxFeeExceedsMaxFee { max_fee: GasCost, tx_fee: GasCost }, #[error(transparent)] GasOverflow(#[from] GasOverflow), + #[error(transparent)] + TxBuilder(#[from] TxBuilderError), } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] diff --git a/services/wallet/src/api.rs b/services/wallet/src/api.rs index c735dbade..1524ca371 100644 --- a/services/wallet/src/api.rs +++ b/services/wallet/src/api.rs @@ -1,8 +1,10 @@ use lb_core::{ header::HeaderId, mantle::{ - Note, SignedMantleTx, TxHash, Value, ops::leader_claim::VoucherCm, tx::MantleTxContext, - tx_builder::MantleTxBuilder, + Note, SignedMantleTx, TxHash, Value, + ops::leader_claim::VoucherCm, + tx::MantleTxContext, + tx_builder::{MantleTxBuilder, TxBuilderError}, }, }; use lb_key_management_system_service::keys::{ @@ -34,6 +36,8 @@ pub enum WalletApiError { RelayRecv(#[from] RecvError), #[error(transparent)] Wallet(#[from] WalletServiceError), + #[error(transparent)] + TxBuilderError(#[from] TxBuilderError), } impl From<(RelayError, WalletMsg)> for WalletApiError { @@ -153,7 +157,7 @@ where ) -> Result, WalletApiError> { let context = self.get_tx_context(tip).await?; let mantle_tx_builder = - MantleTxBuilder::new(context).add_ledger_output(Note::new(amount, recipient_pk)); + MantleTxBuilder::new(context).add_ledger_output(Note::new(amount, recipient_pk))?; let funded_tx_builder = self .fund_tx(tip, mantle_tx_builder, change_pk, funding_pks) .await?; diff --git a/services/wallet/src/lib.rs b/services/wallet/src/lib.rs index f84773883..48f7e8706 100644 --- a/services/wallet/src/lib.rs +++ b/services/wallet/src/lib.rs @@ -27,7 +27,7 @@ use lb_core::{ sdp::{SDPActiveOp, SDPDeclareOp, SDPWithdrawOp}, }, tx::MantleTxContext, - tx_builder::MantleTxBuilder, + tx_builder::{MantleTxBuilder, TxBuilderError}, }, proofs::leader_claim_proof::{Groth16LeaderClaimProof, LeaderClaimPrivate, LeaderClaimPublic}, }; @@ -99,6 +99,9 @@ pub enum WalletServiceError { #[error("Input note {0:?} is missing in ledger")] MissingInputNote(NoteId), + #[error(transparent)] + TxBuilder(#[from] TxBuilderError), + #[error("PoC generation failed: {0:?}")] PoCGenerationFailed(#[from] lb_core::proofs::leader_claim_proof::Error), @@ -752,7 +755,7 @@ where ) -> Result { // Extract input public keys before building the transaction let mut channel_multi_sig_proofs = tx_builder.channel_multi_sig_proofs().clone(); - let mantle_tx = tx_builder.clone().build(); + let mantle_tx = tx_builder.clone().build()?; let tx_hash = mantle_tx.hash(); let mut ops_proofs = Vec::new(); diff --git a/tests/src/common/mantle_inscription.rs b/tests/src/common/mantle_inscription.rs index bdf4443f4..81648d3f4 100644 --- a/tests/src/common/mantle_inscription.rs +++ b/tests/src/common/mantle_inscription.rs @@ -34,12 +34,14 @@ pub fn build_inscription_tx_builder( leader_reward_amount: 0, }; - MantleTxBuilder::new(tx_context).push_op(Op::ChannelInscribe(InscriptionOp { - channel_id, - inscription, - parent: parent.unwrap_or_else(MsgId::root), - signer: signing_key.public_key(), - })) + MantleTxBuilder::new(tx_context) + .push_op(Op::ChannelInscribe(InscriptionOp { + channel_id, + inscription, + parent: parent.unwrap_or_else(MsgId::root), + signer: signing_key.public_key(), + })) + .expect("inscription test builder should fit op bounds") } #[must_use] diff --git a/tests/src/common/wallet/funding.rs b/tests/src/common/wallet/funding.rs index 1311df7f7..d91d9c40e 100644 --- a/tests/src/common/wallet/funding.rs +++ b/tests/src/common/wallet/funding.rs @@ -221,15 +221,8 @@ pub fn build_wallet_funded_transfer( let selected_inputs = selected_inputs.into_utxos(); let transfer = TransferOp { - inputs: Inputs::new( - selected_inputs - .iter() - .map(Utxo::id) - .collect::>() - .try_into() - .expect("Too many inputs for transfer op."), - ), - outputs: Outputs::new(transfer_outputs), + inputs: Inputs::try_new(selected_inputs.iter().map(Utxo::id).collect::>())?, + outputs: Outputs::try_new(transfer_outputs)?, }; Ok(WalletFundedTransfer { diff --git a/tests/src/common/wallet/transaction/builder_funding.rs b/tests/src/common/wallet/transaction/builder_funding.rs index e42231b24..102509ac5 100644 --- a/tests/src/common/wallet/transaction/builder_funding.rs +++ b/tests/src/common/wallet/transaction/builder_funding.rs @@ -105,7 +105,7 @@ fn fund_sponsored_wallet_transaction( sender_change_pk, output_total, sender_inputs.total(), - ); + )?; fund_builder_from_plan( &builder_with_sender_outputs, @@ -122,12 +122,14 @@ fn add_sender_change_output( change_pk: ZkPublicKey, output_total: u64, input_total: u64, -) -> MantleTxBuilder { +) -> Result { let change = input_total - output_total; if change > 0 { - tx_builder.add_ledger_output(Note::new(change, change_pk)) - } else { tx_builder + .add_ledger_output(Note::new(change, change_pk)) + .map_err(Into::into) + } else { + Ok(tx_builder) } } @@ -169,7 +171,7 @@ fn evaluate_standard_funding_inputs( ) -> Result, WalletError> { let funded_builder = tx_builder .clone() - .extend_ledger_inputs(selected_inputs.iter().copied()); + .extend_ledger_inputs(selected_inputs.iter().copied())?; match funded_builder .funding_delta::()? @@ -203,7 +205,7 @@ fn build_chunked_funded_tx( .sum::(); let output_sum = pending_transfer_output_sum(tx_builder); - let chunked_builder = with_transfer_input_chunks(tx_builder, funding_utxos); + let chunked_builder = with_transfer_input_chunks(tx_builder, funding_utxos)?; let funding_delta = funding_delta_for_chunked_builder(&chunked_builder, input_sum, output_sum)?; match funding_delta.cmp(&0) { @@ -228,7 +230,7 @@ fn add_chunked_change_output( ) -> Result, WalletError> { let builder_with_dummy_change = chunked_builder .clone() - .add_ledger_output(Note::new(0, change_pk)); + .add_ledger_output(Note::new(0, change_pk))?; let delta_with_change = funding_delta_for_chunked_builder(&builder_with_dummy_change, input_sum, output_sum)?; @@ -237,7 +239,7 @@ fn add_chunked_change_output( } let change = u64::try_from(delta_with_change).expect("Positive delta must fit in u64"); - let tx_with_change = chunked_builder.add_ledger_output(Note::new(change, change_pk)); + let tx_with_change = chunked_builder.add_ledger_output(Note::new(change, change_pk))?; assert_eq!( funding_delta_for_chunked_builder( @@ -255,7 +257,7 @@ fn add_chunked_change_output( fn with_transfer_input_chunks( tx_builder: &MantleTxBuilder, funding_utxos: &[Utxo], -) -> MantleTxBuilder { +) -> Result { let final_chunk_len = match funding_utxos.len() % super::signing::ZKSIGN_MAX_INPUTS { 0 => super::signing::ZKSIGN_MAX_INPUTS, remainder => remainder, @@ -265,29 +267,27 @@ fn with_transfer_input_chunks( let mut builder = tx_builder.clone(); for chunk in funding_utxos[..split_index].chunks(super::signing::ZKSIGN_MAX_INPUTS) { builder = builder.push_op(Op::Transfer(TransferOp::new( - Inputs::new( - chunk - .iter() - .map(Utxo::id) - .collect::>() - .try_into() - .expect("Too many inputs for transfer op."), - ), - Outputs::new(vec![]), - ))); + Inputs::try_new(chunk.iter().map(Utxo::id).collect::>())?, + Outputs::empty(), + )))?; } - builder.extend_ledger_inputs(funding_utxos[split_index..].iter().copied()) + builder + .extend_ledger_inputs(funding_utxos[split_index..].iter().copied()) + .map_err(Into::into) } fn pending_transfer_output_sum(tx_builder: &MantleTxBuilder) -> u128 { - match tx_builder.clone().build().0.iter().last() { - Some(Op::Transfer(transfer)) => transfer - .outputs - .iter() - .map(|note| u128::from(note.value)) - .sum(), - _ => 0, + match tx_builder.clone().build() { + Ok(tx) => match tx.0.iter().last() { + Some(Op::Transfer(transfer)) => transfer + .outputs + .iter() + .map(|note| u128::from(note.value)) + .sum(), + _ => 0, + }, + Err(_) => 0, } } @@ -349,7 +349,7 @@ mod tests { 0 ); - let funded_tx = funded_builder.build(); + let funded_tx = funded_builder.build().expect("funded builder should build"); let Some(Op::Transfer(transfer)) = funded_tx.ops().last() else { panic!("wallet funding should leave a transfer op at the end"); }; diff --git a/tests/src/common/wallet/transaction/error.rs b/tests/src/common/wallet/transaction/error.rs index 790965331..2e111c02b 100644 --- a/tests/src/common/wallet/transaction/error.rs +++ b/tests/src/common/wallet/transaction/error.rs @@ -1,6 +1,6 @@ //! Shared error type for wallet transaction preparation. -use lb_core::mantle::{NoteId, VerificationError, gas::GasOverflow}; +use lb_core::mantle::{NoteId, VerificationError, gas::GasOverflow, tx_builder::TxBuilderError}; use lb_wallet::WalletError; use lb_zksign::ZkSignError; use thiserror::Error; @@ -19,6 +19,8 @@ pub enum WalletTransactionError { Verification(#[from] VerificationError), #[error(transparent)] Gas(#[from] GasOverflow), + #[error(transparent)] + Builder(#[from] TxBuilderError), #[error("wallet transaction output total overflowed u64")] OutputTotalOverflow, } diff --git a/tests/src/common/wallet/transaction/intent.rs b/tests/src/common/wallet/transaction/intent.rs index 7fdbde90d..a3b16cdf8 100644 --- a/tests/src/common/wallet/transaction/intent.rs +++ b/tests/src/common/wallet/transaction/intent.rs @@ -46,7 +46,7 @@ impl WalletTransactionIntent { let mut tx_builder = MantleTxBuilder::new(empty_context); for (receiver_pk, value) in receivers { - tx_builder = tx_builder.add_ledger_output(Note::new(*value, *receiver_pk)); + tx_builder = tx_builder.add_ledger_output(Note::new(*value, *receiver_pk))?; } Self::from_builder(tx_builder) @@ -60,7 +60,7 @@ impl WalletTransactionIntent { fn transfer_output_total(tx_builder: &MantleTxBuilder) -> Result { tx_builder .clone() - .build() + .build()? .ops() .iter() .filter_map(|op| match op { diff --git a/tests/src/common/wallet/transaction/prepare.rs b/tests/src/common/wallet/transaction/prepare.rs index 6855f1065..7bfe7999c 100644 --- a/tests/src/common/wallet/transaction/prepare.rs +++ b/tests/src/common/wallet/transaction/prepare.rs @@ -24,7 +24,7 @@ pub fn prepare_wallet_transaction( let input_utxos_by_note_id = input_utxos_by_note_id(&resources); let funded_builder = fund_wallet_transaction(intent, resources)?; - let mantle_tx = funded_builder.clone().build(); + let mantle_tx = funded_builder.clone().build()?; let tx_hash = mantle_tx.hash(); let transfer_proofs = build_transfer_proofs(mantle_tx.ops(), &tx_hash, &transfer_signers)?; let funding_inputs = funding_inputs_from_transfers(&mantle_tx, &input_utxos_by_note_id)?; diff --git a/tests/src/common/wallet/transaction/signing.rs b/tests/src/common/wallet/transaction/signing.rs index fa4a60ba9..30ed17d42 100644 --- a/tests/src/common/wallet/transaction/signing.rs +++ b/tests/src/common/wallet/transaction/signing.rs @@ -22,7 +22,7 @@ pub(super) fn sign_prepared_wallet_transaction( leading_op_proofs: Vec, ) -> Result { let gas_prices = funded_builder.get_gas_prices(); - let mantle_tx = funded_builder.build(); + let mantle_tx = funded_builder.build()?; let mut op_proofs = leading_op_proofs; op_proofs.extend(transfer_proofs); diff --git a/tests/src/cucumber/steps/manual_transactions/tracked_transactions.rs b/tests/src/cucumber/steps/manual_transactions/tracked_transactions.rs index c42fe4d09..b84205abb 100644 --- a/tests/src/cucumber/steps/manual_transactions/tracked_transactions.rs +++ b/tests/src/cucumber/steps/manual_transactions/tracked_transactions.rs @@ -181,7 +181,11 @@ async fn transaction_is_in_chain( pub fn create_invalid_transaction() -> SignedMantleTx { let output_note = Note::new(1000, ZkPublicKey::new(1u8.into())); - let transfer_op = TransferOp::new(Inputs::empty(), Outputs::new(vec![output_note])); + let transfer_op = TransferOp::new( + Inputs::empty(), + // Outputs::new([output_note]), + Outputs::new([output_note]), + ); let mantle_tx = MantleTx([Op::Transfer(transfer_op)].into()); diff --git a/tests/src/cucumber/steps/manual_zone/support.rs b/tests/src/cucumber/steps/manual_zone/support.rs index 251741915..3cbc5a102 100644 --- a/tests/src/cucumber/steps/manual_zone/support.rs +++ b/tests/src/cucumber/steps/manual_zone/support.rs @@ -19,7 +19,7 @@ use lb_config::consensus::{ProviderInfo, create_genesis_block_with_declarations} use lb_core::{ mantle::{ GenesisTx as _, MantleTx, Note, Op, OpProof, Transaction as _, Utxo, Value, - ledger::Outputs, + ledger::{Inputs, Outputs, OutputsError}, ops::{ channel::{ ChannelId, @@ -45,7 +45,7 @@ use lb_testing_framework::{ DeploymentBuilder, LbcEnv, LbcLocalDeployer, LbcManualCluster, NodeHttpClient, TopologyConfig, internal::DeploymentPlan, }; -use lb_utils::math::NonNegativeRatio; +use lb_utils::{bounded_vec::BoundedError, math::NonNegativeRatio}; use lb_zone_sdk::{ ZoneMessage, adapter::NodeHttpClient as ZoneNodeHttpClient, @@ -123,6 +123,10 @@ pub enum ZoneTestError { WithdrawTimeout, #[error("zone sequencer event stream stopped before observing the expected event")] SequencerStopped, + #[error(transparent)] + BoundedError(#[from] BoundedError), + #[error(transparent)] + OutputsError(#[from] OutputsError), } /// Prepared deployment resources for the single-node zone test cluster. @@ -1190,7 +1194,7 @@ pub fn build_zone_deposit( Ok(ZoneDeposit { deposit: DepositOp { channel_id, - inputs: note.id().into(), + inputs: Inputs::new([note.id()]), metadata, }, reserved_inputs: vec![note], @@ -1318,7 +1322,7 @@ fn build_atomic_deposit_op( Ok(DepositOp { channel_id, - inputs: deposit_note_id.into(), + inputs: Inputs::new([deposit_note_id]), metadata, }) } @@ -1334,7 +1338,7 @@ pub async fn submit_zone_withdraw( ) -> Result { let withdraw = ChannelWithdrawOp { channel_id, - outputs: Outputs::new(vec![Note::new(amount, funding_public_key)]), + outputs: Outputs::new([Note::new(amount, funding_public_key)]), withdraw_nonce: 0, }; @@ -1424,15 +1428,17 @@ pub async fn publish_atomic_zone_withdraw( } let withdraw_args: Vec = outputs_per_arg .iter() - .map(|amounts| WithdrawArg { - outputs: Outputs::new( - amounts - .iter() - .map(|amount| Note::new(*amount, funding_public_key)) - .collect(), - ), + .map(|amounts| { + Ok::(WithdrawArg { + outputs: Outputs::try_new( + amounts + .iter() + .map(|amount| Note::new(*amount, funding_public_key)) + .collect::>(), + )?, + }) }) - .collect(); + .collect::, _>>()?; sequencer .publish_atomic_withdraw(inscription_data.clone(), withdraw_args) @@ -1549,11 +1555,13 @@ fn add_exact_deposit_notes_to_funding_key( .genesis_transfer() .clone(); - transfer_op.outputs.as_mut().extend( - values - .into_iter() - .map(|value| Note::new(value, funding_public_key)), - ); + for value in values { + transfer_op + .outputs + .as_mut() + .try_push(Note::new(value, funding_public_key)) + .expect("zone helper note set should stay within transfer output bounds"); + } let providers = deployment .nodes() diff --git a/tests/src/cucumber/wallet/submissions.rs b/tests/src/cucumber/wallet/submissions.rs index d795b23e4..224ecdbc1 100644 --- a/tests/src/cucumber/wallet/submissions.rs +++ b/tests/src/cucumber/wallet/submissions.rs @@ -320,6 +320,9 @@ fn wallet_transaction_error(error: WalletTransactionError) -> StepError { WalletTransactionError::Gas(error) => StepError::LogicalError { message: error.to_string(), }, + WalletTransactionError::Builder(error) => StepError::LogicalError { + message: error.to_string(), + }, WalletTransactionError::OutputTotalOverflow => StepError::LogicalError { message: error.to_string(), }, diff --git a/tests/src/tests/mantle/chain_start.rs b/tests/src/tests/mantle/chain_start.rs index edffe2c47..b867ef67f 100644 --- a/tests/src/tests/mantle/chain_start.rs +++ b/tests/src/tests/mantle/chain_start.rs @@ -97,7 +97,8 @@ fn test_config(mut config: RunConfig, genesis_time: OffsetDateTime) -> RunConfig }; config.deployment.cryptarchia.genesis_block = GenesisBlockBuilder::new() - .add_notes(genesis_tx.genesis_transfer().outputs.iter().copied()) + .try_add_notes(genesis_tx.genesis_transfer().outputs.iter().copied()) + .unwrap() .set_inscription(inscription) .build() .expect("Failed to build genesis block"); diff --git a/tests/src/tests/mantle/channel.rs b/tests/src/tests/mantle/channel.rs index 252a829e9..22beea57c 100644 --- a/tests/src/tests/mantle/channel.rs +++ b/tests/src/tests/mantle/channel.rs @@ -7,6 +7,7 @@ use lb_core::{ mantle::{ GenesisTx as _, NoteId, Transaction as _, gas::GasCost, + ledger::Inputs, ops::channel::{ChannelId, deposit::DepositOp}, }, }; @@ -96,7 +97,7 @@ async fn channel_deposit() { assert_eq!(selected_deposit_amount, deposit_amount); let deposit_op = DepositOp { channel_id, - inputs: note_id.into(), + inputs: Inputs::new([note_id]), metadata: format!("Mint {deposit_amount} to Alice in Zone") .into_bytes() .try_into() diff --git a/tests/src/tests/mantle/sdp/ops.rs b/tests/src/tests/mantle/sdp/ops.rs index 35a810929..5d279329f 100644 --- a/tests/src/tests/mantle/sdp/ops.rs +++ b/tests/src/tests/mantle/sdp/ops.rs @@ -475,7 +475,9 @@ async fn fund_sdp_transaction( gas_context: empty_context, leader_reward_amount: 0, }; - let tx_builder = MantleTxBuilder::new(tx_context).push_op(extra_op); + let tx_builder = MantleTxBuilder::new(tx_context) + .push_op(extra_op) + .expect("mixed-op helper should fit op bounds"); let funded_builder = fund_builder_from_wallet_source(&funding_source, &tx_builder) .expect("funding mixed-op transaction should succeed"); @@ -486,5 +488,10 @@ async fn fund_sdp_transaction( .map(|_| funding_wallet.secret_key.clone()) .collect::>(); - (funded_builder.build(), signing_keys) + ( + funded_builder + .build() + .expect("funded mixed-op builder should build"), + signing_keys, + ) } diff --git a/tests/testing_framework/src/node/configs/postprocess.rs b/tests/testing_framework/src/node/configs/postprocess.rs index 1b37aafbe..410f68f78 100644 --- a/tests/testing_framework/src/node/configs/postprocess.rs +++ b/tests/testing_framework/src/node/configs/postprocess.rs @@ -78,7 +78,7 @@ pub fn apply_wallet_genesis_overrides( .expect("Genesis block should have a genesis tx") .genesis_transfer() .clone(); - for output in transfer_op.outputs.as_mut() { + for output in transfer_op.outputs.as_mut().iter_mut() { if leader_keys.contains(&output.pk) { output.value = leader_stake; } @@ -87,7 +87,8 @@ pub fn apply_wallet_genesis_overrides( transfer_op .outputs .as_mut() - .push(Note::new(*value, secret_key.to_public_key())); + .try_push(Note::new(*value, secret_key.to_public_key())) + .expect("wallet account outputs must fit transfer output bounds"); } let genesis_block = diff --git a/tests/testing_framework/src/workloads/transaction/workload.rs b/tests/testing_framework/src/workloads/transaction/workload.rs index 186a452f1..7c1798f25 100644 --- a/tests/testing_framework/src/workloads/transaction/workload.rs +++ b/tests/testing_framework/src/workloads/transaction/workload.rs @@ -286,8 +286,11 @@ fn build_wallet_transaction( let provisional_tx = MantleTxBuilder::new(tx_context.clone()) .add_ledger_input(input.utxo) + .map_err(|err| format!("failed to add provisional input: {err}"))? .add_ledger_output(Note::new(input.utxo.note.value, receiver)) - .build(); + .map_err(|err| format!("failed to add provisional output: {err}"))? + .build() + .map_err(|err| format!("failed to build provisional tx: {err}"))?; let fee = provisional_tx .total_gas_cost::(gas_context)? @@ -301,8 +304,11 @@ fn build_wallet_transaction( let tx = MantleTxBuilder::new(tx_context) .add_ledger_input(input.utxo) + .map_err(|err| format!("failed to add input: {err}"))? .add_ledger_output(Note::new(output_value, receiver)) - .build(); + .map_err(|err| format!("failed to add output: {err}"))? + .build() + .map_err(|err| format!("failed to build tx: {err}"))?; let signature = ZkKey::multi_sign( slice::from_ref(&input.account.secret_key), diff --git a/tools/blockchain-tools/src/bin/genesis.rs b/tools/blockchain-tools/src/bin/genesis.rs index 976c37ecc..550b70e4f 100644 --- a/tools/blockchain-tools/src/bin/genesis.rs +++ b/tools/blockchain-tools/src/bin/genesis.rs @@ -357,7 +357,9 @@ fn build_genesis_block( // Accumulate additional notes into WithNotes state. let mut builder = GenesisBlockBuilder::new().add_note(first_note); for note in notes_iter { - builder = builder.add_note(note); + builder = builder + .try_add_note(note) + .context("failed to append note to genesis transfer")?; } // Transition: WithNotes → WithNotesAndInscription → WithAll. diff --git a/tools/blockchain-tools/src/genesis/distribution.rs b/tools/blockchain-tools/src/genesis/distribution.rs index 15a4e3a6f..09c54d5fe 100644 --- a/tools/blockchain-tools/src/genesis/distribution.rs +++ b/tools/blockchain-tools/src/genesis/distribution.rs @@ -46,7 +46,8 @@ impl GenesisTransferOp { notes.push(Note::new(faucet.funds, faucet.zk_id)); - let outputs = Outputs::new(notes); + let outputs = Outputs::try_new(notes) + .expect("genesis distribution outputs must fit transfer output bounds"); let transfer_op = TransferOp::new(Inputs::empty(), outputs.clone()); Self { diff --git a/tools/config/src/consensus.rs b/tools/config/src/consensus.rs index 887bbe054..618ab528b 100644 --- a/tools/config/src/consensus.rs +++ b/tools/config/src/consensus.rs @@ -127,7 +127,9 @@ pub fn create_genesis_block(utxos: &[Utxo], test_context: Option<&str>) -> Genes let genesis_builder = if let Some(note) = outputs.next() { let mut genesis_builder = GenesisBlockBuilder::new().add_note(note); for note in outputs { - genesis_builder = genesis_builder.add_note(note); + genesis_builder = genesis_builder + .try_add_note(note) + .expect("note count must fit in genesis transfer outputs"); } genesis_builder } else { diff --git a/utils/src/bounded_vec.rs b/utils/src/bounded_vec.rs index 9db7153ad..79970ae44 100644 --- a/utils/src/bounded_vec.rs +++ b/utils/src/bounded_vec.rs @@ -7,7 +7,7 @@ use std::{str::FromStr, vec::IntoIter}; use serde::{Deserialize, Serialize}; use thiserror::Error; -#[derive(Debug, Error, Eq, PartialEq)] +#[derive(Debug, Error, Eq, PartialEq, Clone)] pub enum BoundedError { #[error("Input cannot be empty.")] EmptyInput, @@ -91,6 +91,18 @@ impl BoundedVec { } } +impl Default for BoundedVec { + fn default() -> Self { + const { + assert!( + MIN == 0, + "Default is only valid for BoundedVec with MIN == 0" + ); + } + Self(Vec::new()) + } +} + impl TryFrom> for BoundedVec { type Error = BoundedError; diff --git a/wallet/Cargo.toml b/wallet/Cargo.toml index f99af597a..8c21083e3 100644 --- a/wallet/Cargo.toml +++ b/wallet/Cargo.toml @@ -18,6 +18,7 @@ lb-cryptarchia-engine = { workspace = true } lb-key-management-system-keys = { workspace = true } lb-ledger = { workspace = true } lb-mmr = { workspace = true } +lb-utils = { workspace = true } num-bigint = { workspace = true } rpds = { features = ["serde"], workspace = true } serde = { features = ["derive"], workspace = true } diff --git a/wallet/src/error.rs b/wallet/src/error.rs index 5a641f8ef..61ca39d2c 100644 --- a/wallet/src/error.rs +++ b/wallet/src/error.rs @@ -1,4 +1,12 @@ -use lb_core::{header::HeaderId, mantle::gas::GasOverflow}; +use lb_core::{ + header::HeaderId, + mantle::{ + gas::GasOverflow, + ledger::{InputsError, OutputsError}, + tx_builder::TxBuilderError, + }, +}; +use lb_utils::bounded_vec::BoundedError; use thiserror::Error; #[derive(Error, Debug, PartialEq, Eq)] @@ -9,4 +17,18 @@ pub enum WalletError { InsufficientFunds { available: u64 }, #[error(transparent)] GasOverflow(#[from] GasOverflow), + #[error("Transaction builder error: {0}")] + TxBuilder(String), + #[error(transparent)] + BoundedError(#[from] BoundedError), + #[error(transparent)] + InputsError(#[from] InputsError), + #[error(transparent)] + OutputsError(#[from] OutputsError), +} + +impl From for WalletError { + fn from(error: TxBuilderError) -> Self { + Self::TxBuilder(error.to_string()) + } } diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs index 42bc400e3..4bfb6f60d 100644 --- a/wallet/src/lib.rs +++ b/wallet/src/lib.rs @@ -183,7 +183,7 @@ impl WalletState { for i in 0..utxos.len() { let funded_tx_builder = tx_builder .clone() - .extend_ledger_inputs(utxos[..=i].iter().copied()); + .extend_ledger_inputs(utxos[..=i].iter().copied())?; let funding_delta = funded_tx_builder.funding_delta::()?; @@ -706,7 +706,7 @@ mod tests { // - voucher v1 is ours -> should be tracked let transfer1 = TransferOp { inputs: Inputs::empty(), - outputs: Outputs::new(vec![Note::new(100, alice), Note::new(4, alice)]), + outputs: Outputs::new([Note::new(100, alice), Note::new(4, alice)]), }; // immediately lock the 2nd note from `transfer1` let locked_note = transfer1.outputs.utxo_by_index(1, &transfer1).unwrap().id(); @@ -740,8 +740,8 @@ mod tests { voucher_cm: v2_cm, spent_notes: vec![alice_100_nmo_utxo.id()], transfers: vec![TransferOp { - inputs: [alice_100_nmo_utxo.id()].into(), - outputs: Outputs::new(vec![Note::new(20, bob), Note::new(80, alice)]), + inputs: Inputs::new([alice_100_nmo_utxo.id()]), + outputs: Outputs::new([Note::new(20, bob), Note::new(80, alice)]), }], // Unknown locked note that will be ignored locked_notes: HashSet::from([NoteId::from(Fr::ONE)]), @@ -843,15 +843,15 @@ mod tests { assert_eq!(794, funded_tx_builder.net_balance()); assert_eq!(0, funded_tx_builder.funding_delta::().unwrap()); - let funded_tx = funded_tx_builder.build(); + let funded_tx = funded_tx_builder.build().unwrap(); if let Op::Transfer(transfer_op) = &funded_tx.ops()[funded_tx.ops().len() - 1] { // ensure alices utxo was used to pay the fee - assert_eq!(transfer_op.inputs, utxo2.id().into()); + assert_eq!(transfer_op.inputs, Inputs::new([utxo2.id()])); // ensure change was returned to alice assert_eq!( transfer_op.outputs, - Outputs::new(vec![Note { + Outputs::new([Note { value: 4206, pk: alice, }]) @@ -895,7 +895,7 @@ mod tests { signer: signing_key.public_key(), }); - tx_builder = tx_builder.push_op(inscription); + tx_builder = tx_builder.push_op(inscription).unwrap(); // Fund the transaction let fund_attempt = wallet_state.fund_tx::(&tx_builder, alice, [alice]); @@ -993,6 +993,7 @@ mod tests { tx_builder .clone() .add_ledger_input(Utxo::new(tx_hash(0), 0, Note::new(0, pk(0)))) + .unwrap() .gas_cost::() .unwrap() .into_inner() @@ -1011,13 +1012,14 @@ mod tests { let funded_tx_wo_change = wallet_state .fund_tx::(&tx_builder, alice, [alice]) .unwrap() - .build(); // successfully funded the tx + .build() + .unwrap(); // successfully funded the tx // verify that no change output was used. if let Op::Transfer(transfer_op) = &funded_tx_wo_change.ops()[funded_tx_wo_change.ops().len() - 1] { - assert_eq!(transfer_op.outputs, Outputs::new(vec![])); + assert_eq!(transfer_op.outputs, Outputs::empty()); } else { panic!("last op must be a transfer") } @@ -1028,7 +1030,9 @@ mod tests { tx_builder .clone() .add_ledger_input(Utxo::new(tx_hash(0), 0, Note::new(0, pk(0)))) + .unwrap() .with_dummy_change_note() + .unwrap() .gas_cost::() .unwrap() .into_inner() @@ -1066,13 +1070,14 @@ mod tests { let funded_tx_wo_change = wallet_state .fund_tx::(&tx_builder, alice, [alice]) .unwrap() - .build(); // successfully funded the tx + .build() + .unwrap(); // successfully funded the tx // verify that indeed a change output was used. if let Op::Transfer(transfer_op) = &funded_tx_wo_change.ops()[funded_tx_wo_change.ops().len() - 1] { - assert_eq!(transfer_op.outputs, Outputs::new(vec![Note::new(1, alice)])); + assert_eq!(transfer_op.outputs, Outputs::new([Note::new(1, alice)])); } else { panic!("the last operation must be a transfer") } diff --git a/zone-sdk/src/indexer.rs b/zone-sdk/src/indexer.rs index c373b8833..21febc980 100644 --- a/zone-sdk/src/indexer.rs +++ b/zone-sdk/src/indexer.rs @@ -153,7 +153,7 @@ mod tests { let messages = vec![ (block_msg(1, &[1]), Slot::new(0)), ( - deposit_msg([NoteId::from(Fr::from(10u32))].into(), 0, [10].into()), + deposit_msg(Inputs::new([NoteId::from(Fr::from(10u32))]), 0, [10].into()), Slot::new(0), ), (block_msg(2, &[2]), Slot::new(1)), @@ -173,7 +173,7 @@ mod tests { let messages = vec![ (block_msg(1, &[1]), Slot::new(0)), ( - deposit_msg([NoteId::from(Fr::from(10u32))].into(), 0, [10].into()), + deposit_msg(Inputs::new([NoteId::from(Fr::from(10u32))]), 0, [10].into()), Slot::new(1), ), (block_msg(2, &[2]), Slot::new(2)), // after LIB @@ -192,12 +192,12 @@ mod tests { let messages = vec![ (block_msg(1, &[1]), Slot::new(0)), ( - deposit_msg([NoteId::from(Fr::from(10u32))].into(), 0, [10].into()), + deposit_msg(Inputs::new([NoteId::from(Fr::from(10u32))]), 0, [10].into()), Slot::new(0), ), (block_msg(2, &[2]), Slot::new(1)), ( - deposit_msg([NoteId::from(Fr::from(11u32))].into(), 0, [11].into()), + deposit_msg(Inputs::new([NoteId::from(Fr::from(11u32))]), 0, [11].into()), Slot::new(2), ), (block_msg(3, &[3]), Slot::new(2)), @@ -217,7 +217,7 @@ mod tests { let messages = vec![ (block_msg(1, &[1]), Slot::new(0)), ( - deposit_msg([NoteId::from(Fr::from(10u32))].into(), 0, [10].into()), + deposit_msg(Inputs::new([NoteId::from(Fr::from(10u32))]), 0, [10].into()), Slot::new(0), ), (block_msg(2, &[2]), Slot::new(1)), @@ -252,7 +252,7 @@ mod tests { let messages = vec![ (block_msg(1, &[1]), Slot::new(0)), ( - deposit_msg([NoteId::from(Fr::from(10u32))].into(), 0, [10].into()), + deposit_msg(Inputs::new([NoteId::from(Fr::from(10u32))]), 0, [10].into()), BATCH_SIZE, ), ( @@ -264,7 +264,7 @@ mod tests { BATCH_SIZE.into_inner().checked_mul(2).unwrap().into(), ), ( - deposit_msg([NoteId::from(Fr::from(11u32))].into(), 0, [11].into()), + deposit_msg(Inputs::new([NoteId::from(Fr::from(11u32))]), 0, [11].into()), BATCH_SIZE.into_inner().checked_mul(3).unwrap().into(), ), ( diff --git a/zone-sdk/src/sequencer.rs b/zone-sdk/src/sequencer.rs index 385fe8c5d..180882184 100644 --- a/zone-sdk/src/sequencer.rs +++ b/zone-sdk/src/sequencer.rs @@ -2419,7 +2419,7 @@ fn extract_inscriptions(txs: &[SignedMantleTx], channel_id: ChannelId) -> Vec Ed25519Signature { #[cfg(test)] mod tests { - use async_trait::async_trait; use lb_common_http_client::{ ApiBlock, ApiHeader, BlockInfo, ChainServiceMode, CryptarchiaInfo, State, @@ -2571,6 +2570,7 @@ mod tests { header::ContentId, mantle::{ Note, Utxo, + ledger::Inputs, ops::channel::deposit::{DepositOp, Metadata}, }, proofs::leader_proof::Groth16LeaderProof, @@ -2616,7 +2616,7 @@ mod tests { let (sk, utxo) = utxo_with_sk(); let deposit_op = DepositOp { channel_id, - inputs: [utxo.id()].into(), + inputs: Inputs::new([utxo.id()]), metadata: b"to Alice".into(), }; @@ -2680,7 +2680,7 @@ mod tests { use lb_groth16::Fr; DepositOp { channel_id, - inputs: [NoteId::from(Fr::from(input_seed))].into(), + inputs: Inputs::new([NoteId::from(Fr::from(input_seed))]), metadata, } } @@ -2826,7 +2826,7 @@ mod tests { // finalized view, not a "what we tracked locally" view. let channel_id = ChannelId::from([0; 32]); let other_channel = ChannelId::from([9; 32]); - let outputs = Outputs::new(vec![Note::new( + let outputs = Outputs::new([Note::new( 42, ZkKey::from(BigUint::from(0u64)).to_public_key(), )]); @@ -2901,7 +2901,7 @@ mod tests { // withdraws field populated, so on orphan we emit // OrphanedTx::AtomicWithdraw (not Inscription). let channel_id = ChannelId::from([1u8; 32]); - let outputs = Outputs::new(vec![Note::new( + let outputs = Outputs::new([Note::new( 5, ZkKey::from(BigUint::from(0u64)).to_public_key(), )]);