pallas-txbuilder: thread voting_procedures through StagingTransaction → Conway build

Fills the third of pallas-txbuilder's Conway TODOs (after auxiliary_data
+ certificates):

- StagingTransaction gains `voting_procedures: Option<Vec<u8>>` (opaque
  CBOR — same Eq-derive workaround as auxiliary_data + certificates)
- Builder methods .voting_procedures(cbor_bytes) + .clear_voting_procedures()
- conway::build_conway_raw decodes via VotingProcedures::decode_fragment
  and assigns to TransactionBody.voting_procedures

VotingProcedures is itself a NonEmptyKeyValuePairs<Voter,
NonEmptyKeyValuePairs<GovActionId, VotingProcedure>> map, so callers
pre-assemble the full map and encode once.

Tests:
- voting_procedures_plumb_through_to_tx_body — encode DRepKey vote-Yes
  on a synthetic GovActionId, build, decode, assert round-trip
- no_voting_procedures_means_none — negative path
This commit is contained in:
Kayos 2026-05-06 07:11:23 -07:00
parent 310b0fe562
commit 507fd9da15
3 changed files with 120 additions and 3 deletions

View file

@ -57,7 +57,32 @@ Tests added:
asserts the cert round-trips byte-for-byte. asserts the cert round-trips byte-for-byte.
- `no_certificates_means_none` — negative path. - `no_certificates_means_none` — negative path.
### 3. `pallas-addresses``pub fn new` on `StakeAddress` ### 3. `pallas-txbuilder``voting_procedures` field on `StagingTransaction`
Upstream had `voting_procedures: None, // TODO` in the conway builder.
Without this, **DRep / SPO / committee voting on Conway governance
actions can't ride a tx built with pallas-txbuilder.**
Added 2026-05-06:
- `pub voting_procedures: Option<Vec<u8>>` field (opaque CBOR for the
same `Eq`-derive reason as `auxiliary_data`).
- Builder methods `.voting_procedures(cbor_bytes)` and
`.clear_voting_procedures()`.
- `conway::build_conway_raw` decodes via `VotingProcedures::decode_fragment`
and assigns to `TransactionBody.voting_procedures`.
The `VotingProcedures` type is itself a single map
(`NonEmptyKeyValuePairs<Voter, NonEmptyKeyValuePairs<GovActionId,
VotingProcedure>>`), so callers pre-assemble the full map and encode
once. No staged accumulator needed.
Tests added:
- `voting_procedures_plumb_through_to_tx_body` — encodes a
single DRepKey-vote-Yes on a synthetic GovActionId, builds, decodes,
asserts the vote round-trips byte-for-byte.
- `no_voting_procedures_means_none` — negative path.
### 4. `pallas-addresses``pub fn new` on `StakeAddress`
Upstream defined `pub struct StakeAddress(Network, StakePayload)` with Upstream defined `pub struct StakeAddress(Network, StakePayload)` with
**unexported tuple fields** and no `new()` constructor — so external **unexported tuple fields** and no `new()` constructor — so external
@ -103,6 +128,7 @@ per change) for upstream review ergonomics:
- `pallas-txbuilder: thread auxiliary_data through StagingTransaction → Conway build` - `pallas-txbuilder: thread auxiliary_data through StagingTransaction → Conway build`
- `pallas-txbuilder: thread certificates through StagingTransaction → Conway build` - `pallas-txbuilder: thread certificates through StagingTransaction → Conway build`
- `pallas-txbuilder: thread voting_procedures through StagingTransaction → Conway build`
- `pallas-addresses: pub fn new on StakeAddress` - `pallas-addresses: pub fn new on StakeAddress`
## Change discipline going forward ## Change discipline going forward

View file

@ -7,7 +7,7 @@ use pallas_primitives::{
AuxiliaryData, Certificate, DatumOption, ExUnits as PallasExUnits, NativeScript, NetworkId, AuxiliaryData, Certificate, DatumOption, ExUnits as PallasExUnits, NativeScript, NetworkId,
NonZeroInt, PlutusData, PlutusScript, PostAlonzoTransactionOutput, NonZeroInt, PlutusData, PlutusScript, PostAlonzoTransactionOutput,
PseudoScript as PallasScript, PseudoTransactionOutput, Redeemer, RedeemerTag, PseudoScript as PallasScript, PseudoTransactionOutput, Redeemer, RedeemerTag,
TransactionBody, TransactionInput, Tx, Value, WitnessSet, TransactionBody, TransactionInput, Tx, Value, VotingProcedures, WitnessSet,
}, },
Fragment, NonEmptyKeyValuePairs, NonEmptySet, PositiveCoin, Fragment, NonEmptyKeyValuePairs, NonEmptySet, PositiveCoin,
}; };
@ -261,7 +261,14 @@ impl BuildConway for StagingTransaction {
collateral_return, collateral_return,
reference_inputs, reference_inputs,
total_collateral: None, // TODO total_collateral: None, // TODO
voting_procedures: None, // TODO voting_procedures: self
.voting_procedures
.as_deref()
.map(|bytes| {
VotingProcedures::decode_fragment(bytes)
.map_err(|_| TxBuilderError::CorruptedTxBytes)
})
.transpose()?,
proposal_procedures: None, // TODO proposal_procedures: None, // TODO
treasury_value: None, // TODO treasury_value: None, // TODO
donation: None, // TODO donation: None, // TODO
@ -494,6 +501,67 @@ mod tests {
assert!(body.transaction_body.certificates.is_none()); assert!(body.transaction_body.certificates.is_none());
} }
#[test]
fn voting_procedures_plumb_through_to_tx_body() {
use pallas_primitives::conway::{
GovActionId, Vote, Voter, VotingProcedure, VotingProcedures,
};
use pallas_codec::utils::Nullable;
use pallas_crypto::hash::Hash;
// Construct a single-voter, single-action vote.
let voter_hash = Hash::<28>::from([7u8; 28]);
let voter = Voter::DRepKey(voter_hash);
let action = GovActionId {
transaction_id: Hash::<32>::from([9u8; 32]),
action_index: 0,
};
let procedure = VotingProcedure {
vote: Vote::Yes,
anchor: Nullable::Null,
};
let inner = NonEmptyKeyValuePairs::Def(vec![(action, procedure)]);
let outer: VotingProcedures = NonEmptyKeyValuePairs::Def(vec![(voter, inner)]);
let vp_bytes = minicbor::to_vec(&outer).expect("encode voting procedures");
let tx = StagingTransaction::new()
.voting_procedures(vp_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 vp = body
.transaction_body
.voting_procedures
.expect("voting_procedures field populated");
let outer_vec = vp.to_vec();
assert_eq!(outer_vec.len(), 1);
let (got_voter, inner_kv) = &outer_vec[0];
assert!(matches!(got_voter, Voter::DRepKey(h) if h.as_ref() == &[7u8; 28]));
let inner_vec = inner_kv.to_vec();
assert_eq!(inner_vec.len(), 1);
let (got_action, got_proc) = &inner_vec[0];
assert_eq!(got_action.transaction_id.as_ref(), &[9u8; 32]);
assert_eq!(got_action.action_index, 0);
assert!(matches!(got_proc.vote, Vote::Yes));
}
#[test]
fn no_voting_procedures_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.voting_procedures.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

@ -50,6 +50,12 @@ pub struct StagingTransaction {
/// plumbs them into `TransactionBody.certificates`. Used for /// plumbs them into `TransactionBody.certificates`. Used for
/// stake registration/delegation, pool registration, DRep ops, etc. /// stake registration/delegation, pool registration, DRep ops, etc.
pub certificates: Option<Vec<Vec<u8>>>, pub certificates: Option<Vec<Vec<u8>>>,
/// CBOR-encoded `pallas_primitives::conway::VotingProcedures`. A single
/// blob (the type is `NonEmptyKeyValuePairs<Voter,
/// NonEmptyKeyValuePairs<GovActionId, VotingProcedure>>`, so callers
/// pre-assemble the full map and encode it once). `conway::build_conway_raw`
/// decodes on the way out into `TransactionBody.voting_procedures`.
pub voting_procedures: Option<Vec<u8>>,
// pub withdrawals: TODO // pub withdrawals: TODO
// pub updates: TODO // pub updates: TODO
// pub phase_2_valid: TODO // pub phase_2_valid: TODO
@ -420,6 +426,23 @@ impl StagingTransaction {
self.certificates = None; self.certificates = None;
self self
} }
/// Attach pre-encoded `voting_procedures` (CBOR bytes of a
/// `conway::VotingProcedures`). Callers assemble the full
/// `NonEmptyKeyValuePairs<Voter, NonEmptyKeyValuePairs<GovActionId,
/// VotingProcedure>>` map and pass the encoded bytes here.
///
/// Used for DRep / SPO / committee voting on Conway governance actions.
pub fn voting_procedures(mut self, cbor_bytes: Vec<u8>) -> Self {
self.voting_procedures = Some(cbor_bytes);
self
}
/// Drop any voting procedures previously attached.
pub fn clear_voting_procedures(mut self) -> Self {
self.voting_procedures = None;
self
}
} }
// TODO: Don't want our wrapper types in fields public // TODO: Don't want our wrapper types in fields public