pallas/pallas-txbuilder/src/conway.rs
Cobb 68221fbcb5 pallas-txbuilder: thread certificates through StagingTransaction → Conway build
second of the upstream TODOs. addresses the `// pub certificates: TODO`
comment in transaction/model.rs and the `certificates: None, // TODO`
in conway.rs:243.

implementation:
- new field: pub certificates: Option<Vec<Vec<u8>>> on StagingTransaction.
  cbor bytes per cert (each entry is conway::Certificate) — same opaque-
  bytes pattern as auxiliary_data, for the same reason (Certificate
  doesn't impl Eq).
- builder: .add_certificate(cbor_bytes) and .clear_certificates().
- conway::build_conway_raw decodes each entry via
  Certificate::decode_fragment + plumbs into TransactionBody.certificates
  via NonEmptySet::from_vec. fragment-decode failures map to
  TxBuilderError::CorruptedTxBytes (same as aux_data path).

tests:
- certificates_plumb_through_to_tx_body: encodes a
  StakeRegistration(AddrKeyhash([7;28])), attaches, builds, decodes
  resulting tx, asserts the cert round-trips byte-for-byte.
- no_certificates_means_none: confirms unset path keeps
  body.certificates None (no spurious empty Set).

unblocks aldabra phase 4.6 stake delegation. PR upstream still pending;
this is part of the same vendored fork as the auxiliary_data work.
2026-05-04 12:36:29 -07:00

509 lines
18 KiB
Rust

use std::ops::Deref;
use pallas_codec::utils::CborWrap;
use pallas_crypto::hash::Hash;
use pallas_primitives::{
conway::{
AuxiliaryData, Certificate, DatumOption, ExUnits as PallasExUnits, NativeScript, NetworkId,
NonZeroInt, PlutusData, PlutusScript, PostAlonzoTransactionOutput,
PseudoScript as PallasScript, PseudoTransactionOutput, Redeemer, RedeemerTag,
TransactionBody, TransactionInput, Tx, Value, WitnessSet,
},
Fragment, NonEmptyKeyValuePairs, NonEmptySet, PositiveCoin,
};
use pallas_traverse::ComputeHash;
use crate::{
scriptdata,
transaction::{
model::{
BuilderEra, BuiltTransaction, DatumKind, ExUnits, Output, RedeemerPurpose, ScriptKind,
StagingTransaction,
},
Bytes, Bytes32, TransactionStatus,
},
TxBuilderError,
};
pub trait BuildConway {
fn build_conway_raw(self) -> Result<BuiltTransaction, TxBuilderError>;
// fn build_babbage(staging_tx: StagingTransaction, resolver: (), params: ()) ->
// Result<BuiltTransaction, TxBuilderError>;
}
impl BuildConway for StagingTransaction {
fn build_conway_raw(self) -> Result<BuiltTransaction, TxBuilderError> {
let mut inputs = self
.inputs
.unwrap_or_default()
.iter()
.map(|x| TransactionInput {
transaction_id: x.tx_hash.0.into(),
index: x.txo_index,
})
.collect::<Vec<_>>();
inputs.sort_unstable_by_key(|x| (x.transaction_id, x.index));
let outputs = self
.outputs
.unwrap_or_default()
.iter()
.map(Output::build_babbage_raw)
.collect::<Result<Vec<_>, _>>()?;
let mint = NonEmptyKeyValuePairs::from_vec(
self.mint
.iter()
.flat_map(|x| x.deref().iter())
.map(|(pid, assets)| {
(
Hash::<28>::from(pid.0),
NonEmptyKeyValuePairs::from_vec(
assets
.iter()
.map(|(n, x)| (n.clone().into(), NonZeroInt::try_from(*x).unwrap()))
.collect::<Vec<_>>(),
)
.unwrap(),
)
})
.collect::<Vec<_>>(),
);
let collateral = NonEmptySet::from_vec(
self.collateral_inputs
.unwrap_or_default()
.iter()
.map(|x| TransactionInput {
transaction_id: x.tx_hash.0.into(),
index: x.txo_index,
})
.collect(),
);
let required_signers = NonEmptySet::from_vec(
self.disclosed_signers
.unwrap_or_default()
.iter()
.map(|x| x.0.into())
.collect(),
);
let network_id = if let Some(nid) = self.network_id {
match NetworkId::try_from(nid) {
Err(()) => return Err(TxBuilderError::InvalidNetworkId),
Ok(network_id) => Some(network_id),
}
} else {
None
};
let collateral_return = self
.collateral_output
.as_ref()
.map(Output::build_babbage_raw)
.transpose()?;
let reference_inputs = NonEmptySet::from_vec(
self.reference_inputs
.unwrap_or_default()
.iter()
.map(|x| TransactionInput {
transaction_id: x.tx_hash.0.into(),
index: x.txo_index,
})
.collect(),
);
let (mut native_script, mut plutus_v1_script, mut plutus_v2_script, mut plutus_v3_script) =
(vec![], vec![], vec![], vec![]);
for (_, script) in self.scripts.unwrap_or_default() {
match script.kind {
ScriptKind::Native => {
let script = NativeScript::decode_fragment(&script.bytes.0)
.map_err(|_| TxBuilderError::MalformedScript)?;
native_script.push(script)
}
ScriptKind::PlutusV1 => {
let script = PlutusScript::<1>(script.bytes.into());
plutus_v1_script.push(script)
}
ScriptKind::PlutusV2 => {
let script = PlutusScript::<2>(script.bytes.into());
plutus_v2_script.push(script)
}
ScriptKind::PlutusV3 => {
let script = PlutusScript::<3>(script.bytes.into());
plutus_v3_script.push(script)
}
}
}
let plutus_data = self
.datums
.unwrap_or_default()
.iter()
.map(|x| {
PlutusData::decode_fragment(x.1.as_ref())
.map_err(|_| TxBuilderError::MalformedDatum)
})
.collect::<Result<Vec<_>, _>>()?;
let mut mint_policies = mint
.iter()
.flat_map(|x| x.deref().iter())
.map(|(p, _)| *p)
.collect::<Vec<_>>();
mint_policies.sort_unstable_by_key(|x| *x);
let mut redeemers = vec![];
if let Some(rdmrs) = self.redeemers {
for (purpose, (pd, ex_units)) in rdmrs.deref().iter() {
let ex_units = if let Some(ExUnits { mem, steps }) = ex_units {
PallasExUnits {
mem: *mem,
steps: *steps,
}
} else {
todo!("ExUnits budget calculation not yet implement") // TODO
};
let data = PlutusData::decode_fragment(pd.as_ref())
.map_err(|_| TxBuilderError::MalformedDatum)?;
match purpose {
RedeemerPurpose::Spend(ref txin) => {
let index = inputs
.iter()
.position(|x| {
(*x.transaction_id, x.index) == (txin.tx_hash.0, txin.txo_index)
})
.ok_or(TxBuilderError::RedeemerTargetMissing)?
as u32;
redeemers.push(Redeemer {
tag: RedeemerTag::Spend,
index,
data,
ex_units,
})
}
RedeemerPurpose::Mint(pid) => {
let index = mint_policies
.iter()
.position(|x| x.as_slice() == pid.0)
.ok_or(TxBuilderError::RedeemerTargetMissing)?
as u32;
redeemers.push(Redeemer {
tag: RedeemerTag::Mint,
index,
data,
ex_units,
})
} // todo!("reward and cert redeemers not yet supported"), // TODO
}
}
};
let witness_set_redeemers = pallas_primitives::conway::Redeemers::List(
pallas_codec::utils::MaybeIndefArray::Def(redeemers.clone()),
);
let script_data_hash = self.language_view.map(|language_view| {
let dta = scriptdata::ScriptData {
redeemers: witness_set_redeemers.clone(),
datums: if !plutus_data.is_empty() {
Some(plutus_data.clone())
} else {
None
},
language_view,
};
dta.hash()
});
let mut pallas_tx = Tx {
transaction_body: TransactionBody {
inputs: pallas_primitives::Set::from(inputs),
outputs,
ttl: self.invalid_from_slot,
validity_interval_start: self.valid_from_slot,
fee: self.fee.unwrap_or_default(),
certificates: NonEmptySet::from_vec(
self.certificates
.as_deref()
.unwrap_or(&[])
.iter()
.map(|bytes| {
Certificate::decode_fragment(bytes)
.map_err(|_| TxBuilderError::CorruptedTxBytes)
})
.collect::<Result<Vec<_>, _>>()?,
),
withdrawals: None, // TODO
auxiliary_data_hash: None, // TODO (accept user input)
mint,
script_data_hash,
collateral,
required_signers,
network_id,
collateral_return,
reference_inputs,
total_collateral: None, // TODO
voting_procedures: None, // TODO
proposal_procedures: None, // TODO
treasury_value: None, // TODO
donation: None, // TODO
},
transaction_witness_set: WitnessSet {
vkeywitness: None,
native_script: NonEmptySet::from_vec(native_script),
bootstrap_witness: None,
plutus_v1_script: NonEmptySet::from_vec(plutus_v1_script),
plutus_v2_script: NonEmptySet::from_vec(plutus_v2_script),
plutus_v3_script: NonEmptySet::from_vec(plutus_v3_script),
plutus_data: NonEmptySet::from_vec(plutus_data),
redeemer: if redeemers.is_empty() {
None
} else {
Some(witness_set_redeemers)
},
},
success: true, // TODO
auxiliary_data: self
.auxiliary_data
.as_deref()
.map(AuxiliaryData::decode_fragment)
.transpose()
.map_err(|_| TxBuilderError::CorruptedTxBytes)?
.into(),
};
// TODO: pallas auxiliary_data_hash should be Hash<32> not Bytes
pallas_tx.transaction_body.auxiliary_data_hash = pallas_tx
.auxiliary_data
.clone()
.map(|ad| ad.compute_hash().to_vec().into())
.into();
Ok(BuiltTransaction {
version: self.version,
era: BuilderEra::Conway,
status: TransactionStatus::Built,
tx_hash: Bytes32(*pallas_tx.transaction_body.compute_hash()),
tx_bytes: Bytes(pallas_tx.encode_fragment().unwrap()),
signatures: None,
})
}
// fn build_babbage(staging_tx: StagingTransaction) -> Result<BuiltTransaction,
// TxBuilderError> { todo!()
// }
}
impl Output {
pub fn build_babbage_raw(
&self,
) -> Result<PseudoTransactionOutput<PostAlonzoTransactionOutput>, TxBuilderError> {
let assets = NonEmptyKeyValuePairs::from_vec(
self.assets
.iter()
.flat_map(|x| x.deref().iter())
.map(|(pid, assets)| {
(
pid.0.into(),
assets
.iter()
.map(|(n, x)| (n.clone().into(), PositiveCoin::try_from(*x).unwrap()))
.collect::<Vec<_>>()
.try_into()
.unwrap(),
)
})
.collect::<Vec<_>>(),
);
let value = match assets {
Some(assets) => Value::Multiasset(self.lovelace, assets),
None => Value::Coin(self.lovelace),
};
let datum_option = if let Some(ref d) = self.datum {
match d.kind {
DatumKind::Hash => {
let dh: [u8; 32] = d
.bytes
.as_ref()
.try_into()
.map_err(|_| TxBuilderError::MalformedDatumHash)?;
Some(DatumOption::Hash(dh.into()))
}
DatumKind::Inline => {
let pd = PlutusData::decode_fragment(d.bytes.as_ref())
.map_err(|_| TxBuilderError::MalformedDatum)?;
Some(DatumOption::Data(CborWrap(pd)))
}
}
} else {
None
};
let script_ref = if let Some(ref s) = self.script {
let script = match s.kind {
ScriptKind::Native => PallasScript::NativeScript(
NativeScript::decode_fragment(s.bytes.as_ref())
.map_err(|_| TxBuilderError::MalformedScript)?,
),
ScriptKind::PlutusV1 => PallasScript::PlutusV1Script(PlutusScript::<1>(
s.bytes.as_ref().to_vec().into(),
)),
ScriptKind::PlutusV2 => PallasScript::PlutusV2Script(PlutusScript::<2>(
s.bytes.as_ref().to_vec().into(),
)),
ScriptKind::PlutusV3 => PallasScript::PlutusV3Script(PlutusScript::<3>(
s.bytes.as_ref().to_vec().into(),
)),
};
Some(CborWrap(script))
} else {
None
};
Ok(PseudoTransactionOutput::PostAlonzo(
PostAlonzoTransactionOutput {
address: self.address.to_vec().into(),
value,
datum_option,
script_ref,
},
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::transaction::model::StagingTransaction;
use pallas_codec::minicbor;
use pallas_primitives::alonzo::{AuxiliaryData, Metadatum, PostAlonzoAuxiliaryData};
use pallas_traverse::ComputeHash;
/// Encode a CIP-25-shaped auxiliary data, attach it to a tx, and
/// confirm the built tx body carries the matching auxiliary_data_hash.
/// Round-trip protection for the fork's metadata plumbing.
#[test]
fn auxiliary_data_round_trips_through_build() {
// Minimal CIP-25-style metadata: label 721 → text "test".
let metadata: pallas_primitives::alonzo::Metadata = vec![
(721u64, Metadatum::Text("test".into())),
]
.into();
let aux = AuxiliaryData::PostAlonzo(PostAlonzoAuxiliaryData {
metadata: Some(metadata),
native_scripts: None,
plutus_scripts: None,
});
let aux_bytes = minicbor::to_vec(&aux).expect("encode aux");
let expected_hash = aux.compute_hash();
let tx = StagingTransaction::new()
.auxiliary_data(aux_bytes)
.network_id(0)
.fee(180_000)
.build_conway_raw()
.expect("build_conway_raw");
// The body's auxiliary_data_hash should match the aux's
// compute_hash output.
let body =
pallas_primitives::conway::Tx::decode_fragment(tx.tx_bytes.as_ref())
.expect("decode tx_bytes");
let body_aux_hash_bytes: Vec<u8> = body
.transaction_body
.auxiliary_data_hash
.clone()
.expect("auxiliary_data_hash should be set when aux is attached")
.to_vec();
assert_eq!(body_aux_hash_bytes, expected_hash.to_vec());
// And the auxiliary_data should round-trip back.
let pallas_aux = match body.auxiliary_data {
pallas_codec::utils::Nullable::Some(a) => a,
other => panic!("expected Some(aux), got {other:?}"),
};
// Re-encode the aux that came back and compare.
let round_tripped_bytes = minicbor::to_vec(&pallas_aux).expect("re-encode aux");
let original_again = minicbor::to_vec(&aux).expect("re-encode original");
assert_eq!(round_tripped_bytes, original_again);
}
#[test]
fn certificates_plumb_through_to_tx_body() {
use pallas_primitives::conway::{Certificate, StakeCredential};
use pallas_crypto::hash::Hash;
// A trivial stake-registration cert.
let stake_pkh = Hash::<28>::from([7u8; 28]);
let cert = Certificate::StakeRegistration(StakeCredential::AddrKeyhash(stake_pkh));
let cert_bytes = minicbor::to_vec(&cert).expect("encode cert");
let tx = StagingTransaction::new()
.add_certificate(cert_bytes)
.network_id(0)
.fee(180_000)
.build_conway_raw()
.expect("build_conway_raw");
let body = pallas_primitives::conway::Tx::decode_fragment(tx.tx_bytes.as_ref())
.expect("decode tx_bytes");
let certs = body
.transaction_body
.certificates
.expect("certificates field populated");
let certs_vec: Vec<_> = certs.to_vec();
assert_eq!(certs_vec.len(), 1);
match &certs_vec[0] {
Certificate::StakeRegistration(StakeCredential::AddrKeyhash(h)) => {
assert_eq!(h.as_ref(), &[7u8; 28]);
}
other => panic!("expected StakeRegistration, got {other:?}"),
}
}
#[test]
fn no_certificates_means_none() {
let tx = StagingTransaction::new()
.network_id(0)
.fee(180_000)
.build_conway_raw()
.expect("build_conway_raw");
let body = pallas_primitives::conway::Tx::decode_fragment(tx.tx_bytes.as_ref())
.expect("decode tx_bytes");
assert!(body.transaction_body.certificates.is_none());
}
#[test]
fn no_auxiliary_data_means_no_hash() {
let tx = StagingTransaction::new()
.network_id(0)
.fee(180_000)
.build_conway_raw()
.expect("build_conway_raw");
let body =
pallas_primitives::conway::Tx::decode_fragment(tx.tx_bytes.as_ref())
.expect("decode tx_bytes");
assert!(body.transaction_body.auxiliary_data_hash.is_none());
}
}