pallas-txbuilder: thread auxiliary_data through StagingTransaction → Conway build

addresses the four TODO comments in pallas-txbuilder/src/transaction/model.rs
and conway.rs around auxiliary_data + auxiliary_data_hash. without this the
conway builder hardcodes auxiliary_data: None, which blocks CIP-20 / CIP-25
/ CIP-68 metadata.

implementation:
- new StagingTransaction field: pub auxiliary_data: Option<Vec<u8>>.
  stored as opaque cbor bytes (caller encodes alonzo::AuxiliaryData) since
  AuxiliaryData itself doesn't impl Eq, which the rest of StagingTransaction
  requires.
- builder methods .auxiliary_data(cbor) and .clear_auxiliary_data().
- conway::build_conway_raw now decodes the bytes back into
  AuxiliaryData::decode_fragment and plugs it into pallas_tx.auxiliary_data.
  the existing auxiliary_data_hash population block is unchanged — it
  already computes the hash from pallas_tx.auxiliary_data after assignment.
- decode failures map to TxBuilderError::CorruptedTxBytes.

tests:
- auxiliary_data_round_trips_through_build: encodes a CIP-25-shaped
  metadata, attaches, builds, decodes resulting tx, asserts both
  body.auxiliary_data_hash matches expected_hash.compute_hash() and the
  aux re-encodes byte-equivalent.
- no_auxiliary_data_means_no_hash: confirms the absence path still works
  (no aux → hash field stays None).

both pre-existing tests (staging_json_roundtrip, built_json_roundtrip,
test_script_data_hash) continue to pass — the new field defaults to None
and rides alongside.

PR upstream pending; using as a vendored patch via [patch.crates-io] on
the Sulkta side until merge.
This commit is contained in:
Cobb 2026-05-04 12:04:11 -07:00
parent af0463f5b6
commit 57b36d3a7c
3 changed files with 107 additions and 5 deletions

View file

@ -4,8 +4,8 @@ use pallas_codec::utils::CborWrap;
use pallas_crypto::hash::Hash;
use pallas_primitives::{
conway::{
DatumOption, ExUnits as PallasExUnits, NativeScript, NetworkId, NonZeroInt, PlutusData,
PlutusScript, PostAlonzoTransactionOutput, PseudoScript as PallasScript,
AuxiliaryData, DatumOption, ExUnits as PallasExUnits, NativeScript, NetworkId, NonZeroInt,
PlutusData, PlutusScript, PostAlonzoTransactionOutput, PseudoScript as PallasScript,
PseudoTransactionOutput, Redeemer, RedeemerTag, TransactionBody, TransactionInput, Tx,
Value, WitnessSet,
},
@ -270,8 +270,14 @@ impl BuildConway for StagingTransaction {
Some(witness_set_redeemers)
},
},
success: true, // TODO
auxiliary_data: None.into(), // TODO
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
@ -375,3 +381,74 @@ impl Output {
))
}
}
#[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 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());
}
}

View file

@ -40,10 +40,14 @@ pub struct StagingTransaction {
pub signature_amount_override: Option<u8>,
pub change_address: Option<Address>,
pub language_view: Option<scriptdata::LanguageView>,
/// CBOR-encoded `pallas_primitives::alonzo::AuxiliaryData`. Stored
/// as bytes rather than the typed enum because `AuxiliaryData`
/// doesn't implement `Eq`, which the rest of this struct requires.
/// `conway::build_conway_raw` decodes on the way out.
pub auxiliary_data: Option<Vec<u8>>,
// pub certificates: TODO
// pub withdrawals: TODO
// pub updates: TODO
// pub auxiliary_data: TODO
// pub phase_2_valid: TODO
}
@ -372,6 +376,26 @@ impl StagingTransaction {
self.change_address = None;
self
}
/// Attach pre-encoded auxiliary data (CBOR bytes of an
/// `alonzo::AuxiliaryData`). The conway builder will decode on
/// build and compute `auxiliary_data_hash` automatically.
///
/// Most callers use this for transaction metadata (CIP-25 / CIP-20
/// / CIP-68) and/or to ship native scripts as auxiliary data.
/// Construct an `AuxiliaryData::PostAlonzo(...)` with the
/// metadata + scripts you want, encode with `Fragment::encode_fragment`
/// or `minicbor::to_vec`, and pass the bytes here.
pub fn auxiliary_data(mut self, cbor_bytes: Vec<u8>) -> Self {
self.auxiliary_data = Some(cbor_bytes);
self
}
/// Drop any auxiliary data previously attached.
pub fn clear_auxiliary_data(mut self) -> Self {
self.auxiliary_data = None;
self
}
}
// TODO: Don't want our wrapper types in fields public

View file

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