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.
- `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
**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 certificates through StagingTransaction → Conway build`
- `pallas-txbuilder: thread voting_procedures through StagingTransaction → Conway build`
- `pallas-addresses: pub fn new on StakeAddress`
## Change discipline going forward

View file

@ -7,7 +7,7 @@ use pallas_primitives::{
AuxiliaryData, Certificate, DatumOption, ExUnits as PallasExUnits, NativeScript, NetworkId,
NonZeroInt, PlutusData, PlutusScript, PostAlonzoTransactionOutput,
PseudoScript as PallasScript, PseudoTransactionOutput, Redeemer, RedeemerTag,
TransactionBody, TransactionInput, Tx, Value, WitnessSet,
TransactionBody, TransactionInput, Tx, Value, VotingProcedures, WitnessSet,
},
Fragment, NonEmptyKeyValuePairs, NonEmptySet, PositiveCoin,
};
@ -261,7 +261,14 @@ impl BuildConway for StagingTransaction {
collateral_return,
reference_inputs,
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
treasury_value: None, // TODO
donation: None, // TODO
@ -494,6 +501,67 @@ mod tests {
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]
fn no_auxiliary_data_means_no_hash() {
let tx = StagingTransaction::new()

View file

@ -50,6 +50,12 @@ pub struct StagingTransaction {
/// plumbs them into `TransactionBody.certificates`. Used for
/// stake registration/delegation, pool registration, DRep ops, etc.
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 updates: TODO
// pub phase_2_valid: TODO
@ -420,6 +426,23 @@ impl StagingTransaction {
self.certificates = None;
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