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.
This commit is contained in:
Cobb 2026-05-04 12:36:29 -07:00
parent 51a0d0bd77
commit 68221fbcb5
3 changed files with 87 additions and 7 deletions

View file

@ -4,10 +4,10 @@ use pallas_codec::utils::CborWrap;
use pallas_crypto::hash::Hash; use pallas_crypto::hash::Hash;
use pallas_primitives::{ use pallas_primitives::{
conway::{ conway::{
AuxiliaryData, DatumOption, ExUnits as PallasExUnits, NativeScript, NetworkId, NonZeroInt, AuxiliaryData, Certificate, DatumOption, ExUnits as PallasExUnits, NativeScript, NetworkId,
PlutusData, PlutusScript, PostAlonzoTransactionOutput, PseudoScript as PallasScript, NonZeroInt, PlutusData, PlutusScript, PostAlonzoTransactionOutput,
PseudoTransactionOutput, Redeemer, RedeemerTag, TransactionBody, TransactionInput, Tx, PseudoScript as PallasScript, PseudoTransactionOutput, Redeemer, RedeemerTag,
Value, WitnessSet, TransactionBody, TransactionInput, Tx, Value, WitnessSet,
}, },
Fragment, NonEmptyKeyValuePairs, NonEmptySet, PositiveCoin, Fragment, NonEmptyKeyValuePairs, NonEmptySet, PositiveCoin,
}; };
@ -240,8 +240,18 @@ impl BuildConway for StagingTransaction {
ttl: self.invalid_from_slot, ttl: self.invalid_from_slot,
validity_interval_start: self.valid_from_slot, validity_interval_start: self.valid_from_slot,
fee: self.fee.unwrap_or_default(), fee: self.fee.unwrap_or_default(),
certificates: None, // TODO certificates: NonEmptySet::from_vec(
withdrawals: None, // TODO 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) auxiliary_data_hash: None, // TODO (accept user input)
mint, mint,
script_data_hash, script_data_hash,
@ -439,6 +449,51 @@ mod tests {
assert_eq!(round_tripped_bytes, original_again); 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] #[test]
fn no_auxiliary_data_means_no_hash() { fn no_auxiliary_data_means_no_hash() {
let tx = StagingTransaction::new() let tx = StagingTransaction::new()

View file

@ -45,7 +45,11 @@ pub struct StagingTransaction {
/// doesn't implement `Eq`, which the rest of this struct requires. /// doesn't implement `Eq`, which the rest of this struct requires.
/// `conway::build_conway_raw` decodes on the way out. /// `conway::build_conway_raw` decodes on the way out.
pub auxiliary_data: Option<Vec<u8>>, pub auxiliary_data: Option<Vec<u8>>,
// pub certificates: TODO /// CBOR-encoded `pallas_primitives::conway::Certificate` entries,
/// in tx order. `conway::build_conway_raw` decodes each one and
/// plumbs them into `TransactionBody.certificates`. Used for
/// stake registration/delegation, pool registration, DRep ops, etc.
pub certificates: Option<Vec<Vec<u8>>>,
// pub withdrawals: TODO // pub withdrawals: TODO
// pub updates: TODO // pub updates: TODO
// pub phase_2_valid: TODO // pub phase_2_valid: TODO
@ -396,6 +400,26 @@ impl StagingTransaction {
self.auxiliary_data = None; self.auxiliary_data = None;
self self
} }
/// Append a CBOR-encoded `conway::Certificate` to the tx body.
/// Common cases: stake registration, stake delegation, DRep
/// registration, pool retirement.
///
/// Encode the typed `Certificate` via
/// `pallas_codec::minicbor::to_vec(&cert)` or
/// `Fragment::encode_fragment` and pass the bytes here.
pub fn add_certificate(mut self, cbor_bytes: Vec<u8>) -> Self {
let mut certs = self.certificates.unwrap_or_default();
certs.push(cbor_bytes);
self.certificates = Some(certs);
self
}
/// Drop any certificates previously attached.
pub fn clear_certificates(mut self) -> Self {
self.certificates = None;
self
}
} }
// TODO: Don't want our wrapper types in fields public // TODO: Don't want our wrapper types in fields public

View file

@ -481,6 +481,7 @@ mod tests {
script_data_hash: Some(Bytes32([0; 32])), script_data_hash: Some(Bytes32([0; 32])),
language_view: Some(crate::scriptdata::LanguageView(1, vec![1, 2, 3])), language_view: Some(crate::scriptdata::LanguageView(1, vec![1, 2, 3])),
auxiliary_data: None, auxiliary_data: None,
certificates: None,
}; };
let serialised_tx = serde_json::to_string(&tx).unwrap(); let serialised_tx = serde_json::to_string(&tx).unwrap();