Add tx list serde and gas tests.

This commit is contained in:
Alejandro Cabeza Romero
2026-08-26 23:10:29 +02:00
parent 638030d5ad
commit 4d4bafdd7b
5 changed files with 556 additions and 102 deletions
@@ -32,3 +32,51 @@ impl Serialize for OpProofRefs<'_> {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
codec::SerializeOp as _,
mantle::{OpProof, ops::NoOpProof, transactions::OpProofs},
};
#[test]
fn serialize_to_json() {
let op_proofs = OpProofs::sample();
let op_proof_refs =
OpProofRefs::try_from(op_proofs.iter().map(OpProof::by_ref).collect::<Vec<_>>())
.expect("the sample is within bounds");
assert_eq!(
serde_json::to_value(&op_proof_refs).expect("the human-readable arm serializes"),
serde_json::to_value(op_proof_refs.as_slice()).expect("the inner column serializes")
);
}
#[test]
fn serialize_to_json_round_trips_into_owned_op_proofs() {
let op_proofs = OpProofs::sample();
let op_proof_refs =
OpProofRefs::try_from(op_proofs.iter().map(OpProof::by_ref).collect::<Vec<_>>())
.expect("the sample is within bounds");
let json =
serde_json::to_string(&op_proof_refs).expect("the human-readable arm serializes");
assert_eq!(
serde_json::from_str::<OpProofs>(&json).expect("the human-readable arm deserializes"),
op_proofs
);
}
#[test]
fn serialize_rejects_binary() {
let op_proof = OpProof::None(NoOpProof);
let op_proof_refs = OpProofRefs::from([op_proof.by_ref()]);
op_proof_refs
.to_bytes()
.expect_err("Context-less binary encoding is unsupported.");
}
}
@@ -87,3 +87,25 @@ impl<'de> Deserialize<'de> for OpProofs {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::codec::DeserializeOp as _;
#[test]
fn deserialize_from_json() {
let op_proofs = OpProofs::sample();
let json = serde_json::to_value(op_proofs.inner()).expect("the inner column serializes");
assert_eq!(
serde_json::from_value::<OpProofs>(json).expect("the human-readable arm deserializes"),
op_proofs
);
}
#[test]
fn deserialize_rejects_binary() {
OpProofs::from_bytes(&[0u8; 8]).expect_err("Context-less binary decoding is unsupported.");
}
}
@@ -63,3 +63,74 @@ impl Serialize for OpRefs<'_> {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::codec::{DeserializeOp as _, SerializeOp as _};
#[test]
fn get_returns_the_op_at_the_index_or_none_past_the_end() {
let ops = Ops::sample();
let last = ops.len() - 1;
let references = OpRefs::from(&ops);
assert_eq!(references.get(0), Some(ops[0].by_ref()));
assert_eq!(references.get(last), Some(ops[last].by_ref()));
assert_eq!(references.get(ops.len()), None);
}
#[test]
fn hash_matches_the_ops_it_borrows() {
let ops = Ops::sample();
assert_eq!(OpRefs::from(&ops).hash(), ops.hash());
}
#[test]
fn serialize_to_json() {
let ops = Ops::sample();
let op_refs = OpRefs::from(&ops);
assert_eq!(
serde_json::to_value(&op_refs).expect("the human-readable arm serializes"),
serde_json::to_value(op_refs.inner()).expect("the inner column serializes")
);
}
#[test]
fn serialize_to_json_round_trips_into_ops() {
let ops = Ops::sample();
let json =
serde_json::to_string(&OpRefs::from(&ops)).expect("the human-readable arm serializes");
assert_eq!(
serde_json::from_str::<Ops>(&json).expect("the human-readable arm deserializes"),
ops
);
}
#[test]
fn serialize_to_binary() {
let ops = Ops::sample();
let op_refs = OpRefs::from(&ops);
assert_eq!(
op_refs.to_bytes().expect("the reference view serializes"),
bincode::serialize(&op_refs.encode().into_vec()).expect("the envelope serializes")
);
}
#[test]
fn serialize_to_binary_round_trips_into_ops() {
let ops = Ops::sample();
let bytes = OpRefs::from(&ops)
.to_bytes()
.expect("the reference view serializes");
assert_eq!(
Ops::from_bytes(&bytes).expect("the owned column deserializes"),
ops
);
}
}
+161 -7
View File
@@ -347,22 +347,176 @@ pub mod mantle_spec {
#[cfg(test)]
mod tests {
use super::*;
use crate::{
codec::{DeserializeOp as _, SerializeOp as _},
mantle::gas::MainnetGasProfile,
};
const CONFIGURATION_THRESHOLD: ChannelKeyIndex = 3;
const TRANSFER_THRESHOLD: ChannelKeyIndex = 2;
#[test]
fn binary_serde_rejects_trailing_bytes_inside_transaction_envelope() {
let tx = Ops::empty();
let mut encoded_tx = tx.encode().into_vec();
encoded_tx.push(0);
let envelope = bincode::serialize(&encoded_tx).unwrap();
fn minimum_total_gas_cost_sums_execution_and_storage() {
let ops = Ops::from([Op::ChannelInscribe(InscriptionOp::sample())]);
let context = OpsGasContext::new(HashMap::new(), HashMap::new(), GasPrices::new(2, 3));
assert_eq!(
ops.minimum_total_gas_cost::<MainnetGasProfile>(&context),
Ok(GasCost::new(643))
);
}
#[test]
fn minimum_execution_gas_consumption_uses_channel_thresholds() {
let ops = Ops::from([
Op::ChannelConfig(ChannelConfigOp::sample()),
Op::ChannelDeposit(DepositOp::sample()),
Op::ChannelWithdraw(ChannelWithdrawOp::sample()),
]);
let context = OpsGasContext::new(
[(ChannelWithdrawOp::sample().channel_id, TRANSFER_THRESHOLD)].into(),
[(ChannelConfigOp::sample().channel, CONFIGURATION_THRESHOLD)].into(),
GasPrices::new(1, 0),
);
assert_eq!(
ops.minimum_execution_gas_consumption::<MainnetGasProfile>(&context),
Ok(Gas::from(168 + 590 + 112))
);
}
#[test]
fn minimum_execution_gas_consumption_charges_nothing_for_a_channel_the_context_does_not_hold() {
let ops = Ops::from([
Op::ChannelConfig(ChannelConfigOp::sample()),
Op::ChannelWithdraw(ChannelWithdrawOp::sample()),
Op::ChannelTransfer(ChannelTransferOp::sample()),
]);
assert_eq!(
ops.minimum_execution_gas_consumption::<MainnetGasProfile>(&OpsGasContext::default()),
Ok(Gas::from(0))
);
}
#[test]
fn minimum_execution_gas_consumption_charges_the_flat_cost_of_an_op_without_a_channel() {
let ops = Ops::from([Op::ChannelDeposit(DepositOp::sample())]);
assert_eq!(
ops.minimum_execution_gas_consumption::<MainnetGasProfile>(&OpsGasContext::default()),
Ok(Gas::from(590))
);
}
#[test]
fn minimum_storage_gas_cost_prices_the_signed_size() {
let ops = Ops::from([Op::ChannelInscribe(InscriptionOp::sample())]);
let context = OpsGasContext::new(HashMap::new(), HashMap::new(), GasPrices::new(2, 3));
assert_eq!(
ops.minimum_storage_gas_cost(&context),
Ok(GasCost::new(531))
);
}
#[test]
fn minimum_signed_serialized_size_counts_the_length_prefix_of_an_empty_column() {
assert_eq!(
Ops::empty().minimum_signed_serialized_size(&OpsGasContext::default()),
1
);
}
#[test]
fn minimum_signed_serialized_size_adds_the_proof_each_op_will_carry() {
let ops = Ops::from([Op::ChannelInscribe(InscriptionOp::sample())]);
assert_eq!(
ops.minimum_signed_serialized_size(&OpsGasContext::default()),
177
);
}
#[test]
fn serialize_to_json() {
let ops = Ops::sample();
assert_eq!(
serde_json::to_value(&ops).expect("the human-readable arm serializes"),
serde_json::to_value(ops.inner()).expect("the inner column serializes")
);
}
#[test]
fn serialize_to_binary() {
let ops = Ops::sample();
assert_eq!(
ops.to_bytes().expect("the binary arm serializes"),
bincode::serialize(&ops.encode().into_vec()).expect("the envelope serializes")
);
}
#[test]
fn deserialize_from_json() {
let ops = Ops::sample();
let json = serde_json::to_value(ops.inner()).expect("the inner column serializes");
assert_eq!(
serde_json::from_value::<Ops>(json).expect("the human-readable arm deserializes"),
ops
);
}
#[test]
fn deserialize_from_binary() {
let ops = Ops::sample();
let envelope =
bincode::serialize(&ops.encode().into_vec()).expect("the envelope serializes");
assert_eq!(
Ops::from_bytes(&envelope).expect("the binary arm deserializes"),
ops
);
}
#[test]
fn deserialize_from_binary_rejects_trailing_bytes() {
let mut encoded_ops = Ops::empty().encode().into_vec();
encoded_ops.push(0);
let envelope = bincode::serialize(&encoded_ops).expect("the envelope serializes");
assert!(bincode::deserialize::<Ops>(&envelope).is_err());
}
#[test]
fn binary_serde_rejects_oversized_transaction_envelope() {
fn deserialize_from_binary_rejects_an_oversized_envelope() {
let oversized = vec![0u8; MAX_BLOCK_TRANSACTIONS_SIZE + 1];
let envelope = bincode::serialize(&oversized).unwrap();
let envelope = bincode::serialize(&oversized).expect("the envelope serializes");
assert!(bincode::deserialize::<Ops>(&envelope).is_err());
}
#[test]
fn mantle_spec_serialize_wraps_the_column_in_an_ops_field() {
let ops = Ops::sample();
assert_eq!(
mantle_spec::serialize(&ops, serde_json::value::Serializer)
.expect("the human-readable arm serializes"),
serde_json::json!({ "ops": serde_json::to_value(&ops).expect("the column serializes") })
);
}
#[test]
fn mantle_spec_deserialize_reads_the_ops_field() {
let ops = Ops::sample();
let json = serde_json::json!({ "ops": serde_json::to_value(&ops).expect("the column serializes") });
assert_eq!(
mantle_spec::deserialize(json).expect("the human-readable arm deserializes"),
ops
);
}
}
@@ -492,34 +492,51 @@ pub mod test_utils {
#[cfg(test)]
mod tests {
use lb_codec::BinaryEncode as _;
use lb_groth16::Fr;
use lb_key_management_system_keys::keys::{Ed25519Key, ZkKey};
use num_bigint::BigUint;
use crate::mantle::{
Note, NoteId, Op, OpProof, SignedOps, TxGasCalculator, Utxo, VerificationError,
channel::Error,
gas::MainnetGasProfile,
ledger::{Inputs, Outputs, OutputsError, verification_mode::StandardMode},
ops::{
channel::{
ChannelId, config::ChannelConfigOp, deposit::DepositOp,
verification::test_utils::create_channel_multi_sig_proof,
withdraw::ChannelWithdrawOp,
use super::Error as SignedOpsError;
use crate::{
codec::{DeserializeOp as _, SerializeOp as _},
mantle::{
Note, NoteId, Op, OpProof, SignedOps, TxGasCalculator, Utxo, VerificationError,
channel::Error,
gas::{Gas, GasCost, MainnetGasProfile},
ledger::{Inputs, Outputs, OutputsError, verification_mode::StandardMode},
ops::{
channel::{
ChannelId, config::ChannelConfigOp, deposit::DepositOp,
inscribe::InscriptionOp,
verification::test_utils::create_channel_multi_sig_proof,
withdraw::ChannelWithdrawOp,
},
transfer::{TransferError, TransferOp},
},
transfer::{TransferError, TransferOp},
},
traits::Hashable as _,
transactions::{
GasPrices, OpProofs,
states::{Preverified, Unverified},
tx_list::{
ops::OpsGasContext,
signed_ops::test_utils::{create_test_inscribe_op, create_test_mantle_tx},
traits::Hashable as _,
transactions::{
GasPrices, OpProofs, Ops,
states::{Preverified, Unverified},
tx_list::signed_ops::test_utils::{create_test_inscribe_op, create_test_mantle_tx},
},
},
};
fn sample_columns() -> (Ops, OpProofs) {
let ops = Ops::sample();
let op_proofs = OpProofs::new_unchecked(ops.iter().map(Op::sample_proof).collect());
(ops, op_proofs)
}
fn mantle_spec_json(ops: &Ops, op_proofs: &OpProofs) -> serde_json::Value {
serde_json::json!({
"mantle_tx": { "ops": serde_json::to_value(ops).expect("the op column serializes") },
"ops_proofs": serde_json::to_value(op_proofs.inner())
.expect("the proof column serializes"),
})
}
fn create_config_op(channel: ChannelId, signing_key: &Ed25519Key) -> ChannelConfigOp {
ChannelConfigOp {
channel,
@@ -539,6 +556,17 @@ mod tests {
}
}
fn inscribe_ops() -> Ops {
Ops::from([Op::ChannelInscribe(InscriptionOp::sample())])
}
fn two_column_ops() -> Ops {
Ops::from([
Op::ChannelInscribe(InscriptionOp::sample()),
Op::ChannelDeposit(DepositOp::sample()),
])
}
fn create_withdraw_op(channel_id: ChannelId) -> ChannelWithdrawOp {
ChannelWithdrawOp {
channel_id,
@@ -547,88 +575,36 @@ mod tests {
}
#[test]
fn unsigned_execution_gas_uses_channel_thresholds() {
let signing_key = Ed25519Key::from_bytes(&[1; 32]);
fn from_parts_rejects_a_proof_column_of_a_different_length() {
let ops = two_column_ops();
let op_proofs = OpProofs::new_unchecked(vec![ops[0].sample_proof()]);
let config_channel = ChannelId::from([2; 32]);
let deposit_channel = ChannelId::from([3; 32]);
let withdraw_channel = ChannelId::from([4; 32]);
let mantle_tx = create_test_mantle_tx(vec![
Op::ChannelConfig(create_config_op(config_channel, &signing_key)),
Op::ChannelDeposit(create_deposit_op(deposit_channel)),
Op::ChannelWithdraw(create_withdraw_op(withdraw_channel)),
]);
let config_threshold = 3;
let transfer_threshold = 2;
let context = OpsGasContext::new(
[(withdraw_channel, transfer_threshold)].into(),
[(config_channel, config_threshold)].into(),
GasPrices::new(1, 0),
);
let gas = mantle_tx
.minimum_execution_gas_consumption::<MainnetGasProfile>(&context)
.unwrap();
let expected_config_gas = u64::from(config_threshold) * 56;
let expected_deposit_gas = 590;
let expected_withdraw_gas = u64::from(transfer_threshold) * 56;
let expected_total_gas = expected_config_gas + expected_deposit_gas + expected_withdraw_gas;
assert_eq!(gas.into_inner(), expected_total_gas);
assert!(matches!(
SignedOps::<Unverified, StandardMode>::from_parts(ops, op_proofs),
Err(SignedOpsError::LengthMismatch {
operations: 2,
proofs: 1
})
));
}
#[test]
fn signed_execution_gas_uses_multi_signature_proof_lengths() {
let config_keys = [
Ed25519Key::from_bytes(&[1; 32]),
Ed25519Key::from_bytes(&[2; 32]),
Ed25519Key::from_bytes(&[3; 32]),
];
let withdraw_keys = [
Ed25519Key::from_bytes(&[4; 32]),
Ed25519Key::from_bytes(&[5; 32]),
];
let config_signers = [&config_keys[0], &config_keys[1], &config_keys[2]];
let withdraw_signers = [&withdraw_keys[0], &withdraw_keys[1]];
let config_channel = ChannelId::from([6; 32]);
let deposit_channel = ChannelId::from([7; 32]);
let withdraw_channel = ChannelId::from([8; 32]);
let mantle_tx = create_test_mantle_tx(vec![
Op::ChannelConfig(create_config_op(config_channel, &config_keys[0])),
Op::ChannelDeposit(create_deposit_op(deposit_channel)),
Op::ChannelWithdraw(create_withdraw_op(withdraw_channel)),
fn from_parts_reports_the_index_of_the_mismatched_proof() {
let ops = Ops::from([
Op::ChannelInscribe(InscriptionOp::sample()),
Op::ChannelDeposit(DepositOp::sample()),
Op::ChannelInscribe(InscriptionOp::sample()),
]);
let op_proofs = OpProofs::new_unchecked(vec![
ops[0].sample_proof(),
ops[2].sample_proof(),
ops[2].sample_proof(),
]);
let tx_hash = mantle_tx.hash();
let config_proof = create_channel_multi_sig_proof(&tx_hash, &config_signers);
let deposit_proof = ZkKey::multi_sign(&[], &tx_hash.to_fr()).unwrap();
let withdraw_proof = create_channel_multi_sig_proof(&tx_hash, &withdraw_signers);
let op_proofs = OpProofs::from([
OpProof::ChannelMultiSigProof(config_proof),
OpProof::ZkSig(deposit_proof),
OpProof::ChannelMultiSigProof(withdraw_proof),
]);
let signed_ops = SignedOps::<_, StandardMode>::from_parts(mantle_tx, op_proofs).unwrap();
let gas_prices = GasPrices::new(1, 0);
let gas = TxGasCalculator::execution_gas_consumption::<MainnetGasProfile>(
&signed_ops,
&gas_prices,
)
.unwrap();
let expected_config_gas = config_keys.len() as u64 * 56;
let expected_deposit_gas = 590;
let expected_withdraw_gas = withdraw_keys.len() as u64 * 56;
let expected_total_gas = expected_config_gas + expected_deposit_gas + expected_withdraw_gas;
assert_eq!(gas.into_inner(), expected_total_gas);
assert!(matches!(
SignedOps::<Unverified, StandardMode>::from_parts(ops, op_proofs),
Err(SignedOpsError::OpProofMismatch { index: 1, .. })
));
}
#[test]
@@ -758,6 +734,189 @@ mod tests {
);
}
#[test]
fn trusted_constructors_skip_preverification() {
let signing_key = Ed25519Key::from_bytes(&[1; 32]);
let wrong_key = Ed25519Key::from_bytes(&[2; 32]);
let ops = create_test_mantle_tx(vec![Op::ChannelInscribe(create_test_inscribe_op(
&signing_key,
))]);
let op_proofs = OpProofs::from([OpProof::Ed25519Sig(
wrong_key.sign_payload(&ops.hash().as_signing_bytes()),
)]);
assert!(
SignedOps::<Unverified, StandardMode>::from_parts(ops.clone(), op_proofs.clone())
.expect("the proof matches the op")
.preverify()
.is_err()
);
let trusted = SignedOps::<Preverified, StandardMode>::from_parts_trusted(
ops.clone(),
op_proofs.clone(),
)
.expect("the proof matches the op");
assert_eq!(trusted.op_refs(), ops.by_ref());
assert_eq!(
SignedOps::<Unverified, StandardMode>::from_parts(ops, op_proofs)
.expect("the proof matches the op")
.into_preverified_trusted(),
trusted
);
}
#[test]
fn changing_a_proof_does_not_change_the_transaction_hash() {
let signing_key = Ed25519Key::from_bytes(&[1; 32]);
let other_key = Ed25519Key::from_bytes(&[2; 32]);
let ops = create_test_mantle_tx(vec![Op::ChannelInscribe(create_test_inscribe_op(
&signing_key,
))]);
let tx_hash = ops.hash();
let signed = SignedOps::<Unverified, StandardMode>::from_parts(
ops.clone(),
OpProofs::from([OpProof::Ed25519Sig(
signing_key.sign_payload(&tx_hash.as_signing_bytes()),
)]),
)
.expect("the proof matches the op");
let resigned = SignedOps::<Unverified, StandardMode>::from_parts(
ops,
OpProofs::from([OpProof::Ed25519Sig(
other_key.sign_payload(&tx_hash.as_signing_bytes()),
)]),
)
.expect("the proof matches the op");
assert_ne!(signed, resigned);
assert_eq!(signed.hash(), tx_hash);
assert_eq!(resigned.hash(), tx_hash);
}
#[test]
fn signed_execution_gas_uses_multi_signature_proof_lengths() {
let config_keys = [
Ed25519Key::from_bytes(&[1; 32]),
Ed25519Key::from_bytes(&[2; 32]),
Ed25519Key::from_bytes(&[3; 32]),
];
let withdraw_keys = [
Ed25519Key::from_bytes(&[4; 32]),
Ed25519Key::from_bytes(&[5; 32]),
];
let config_signers = [&config_keys[0], &config_keys[1], &config_keys[2]];
let withdraw_signers = [&withdraw_keys[0], &withdraw_keys[1]];
let config_channel = ChannelId::from([6; 32]);
let deposit_channel = ChannelId::from([7; 32]);
let withdraw_channel = ChannelId::from([8; 32]);
let mantle_tx = create_test_mantle_tx(vec![
Op::ChannelConfig(create_config_op(config_channel, &config_keys[0])),
Op::ChannelDeposit(create_deposit_op(deposit_channel)),
Op::ChannelWithdraw(create_withdraw_op(withdraw_channel)),
]);
let tx_hash = mantle_tx.hash();
let config_proof = create_channel_multi_sig_proof(&tx_hash, &config_signers);
let deposit_proof = ZkKey::multi_sign(&[], &tx_hash.to_fr()).unwrap();
let withdraw_proof = create_channel_multi_sig_proof(&tx_hash, &withdraw_signers);
let op_proofs = OpProofs::from([
OpProof::ChannelMultiSigProof(config_proof),
OpProof::ZkSig(deposit_proof),
OpProof::ChannelMultiSigProof(withdraw_proof),
]);
let signed_ops = SignedOps::<_, StandardMode>::from_parts(mantle_tx, op_proofs).unwrap();
let gas_prices = GasPrices::new(1, 0);
let gas = TxGasCalculator::execution_gas_consumption::<MainnetGasProfile>(
&signed_ops,
&gas_prices,
)
.unwrap();
let expected_config_gas = config_keys.len() as u64 * 56;
let expected_deposit_gas = 590;
let expected_withdraw_gas = withdraw_keys.len() as u64 * 56;
let expected_total_gas = expected_config_gas + expected_deposit_gas + expected_withdraw_gas;
assert_eq!(gas.into_inner(), expected_total_gas);
}
#[test]
fn storage_gas_consumption_counts_the_signed_encoded_bytes() {
let signed_ops =
SignedOps::<Unverified, StandardMode>::from_ops_with_sample_proofs(inscribe_ops());
assert_eq!(
TxGasCalculator::storage_gas_consumption(&signed_ops, &GasPrices::new(2, 3)),
Ok(Gas::from(177))
);
}
#[test]
fn total_gas_cost_sums_execution_and_storage() {
let signed_ops =
SignedOps::<Unverified, StandardMode>::from_ops_with_sample_proofs(inscribe_ops());
assert_eq!(
signed_ops.total_gas_cost::<MainnetGasProfile>(&GasPrices::new(2, 3)),
Ok(GasCost::new(643))
);
}
#[test]
fn serialize_to_json() {
let (ops, op_proofs) = sample_columns();
let signed_ops =
SignedOps::<Unverified, StandardMode>::from_parts(ops.clone(), op_proofs.clone())
.expect("sample proofs pair with their ops");
assert_eq!(
serde_json::to_value(&signed_ops).expect("the human-readable arm serializes"),
mantle_spec_json(&ops, &op_proofs)
);
}
#[test]
fn serialize_to_binary() {
let signed_ops = SignedOps::<Unverified, StandardMode>::sample();
assert_eq!(
signed_ops.to_bytes().expect("the binary arm serializes"),
bincode::serialize(&signed_ops.encode_to_vec()).expect("the envelope serializes")
);
}
#[test]
fn deserialize_from_json() {
let (ops, op_proofs) = sample_columns();
let json = mantle_spec_json(&ops, &op_proofs);
assert_eq!(
serde_json::from_value::<SignedOps<Unverified, StandardMode>>(json)
.expect("the human-readable arm deserializes"),
SignedOps::from_parts(ops, op_proofs).expect("sample proofs pair with their ops")
);
}
#[test]
fn deserialize_from_binary() {
let signed_ops = SignedOps::<Unverified, StandardMode>::sample();
let envelope =
bincode::serialize(&signed_ops.encode_to_vec()).expect("the envelope serializes");
assert_eq!(
SignedOps::<Unverified, StandardMode>::from_bytes(&envelope)
.expect("the binary arm deserializes"),
signed_ops
);
}
#[test]
fn test_signed_mantle_tx_deserialize_with_valid_proof() {
let signing_key = Ed25519Key::from_bytes(&[1; 32]);