From 57b36d3a7c840569d4d86f6d805a42aa4cf85347 Mon Sep 17 00:00:00 2001 From: Cobb Date: Mon, 4 May 2026 12:04:11 -0700 Subject: [PATCH 1/8] =?UTF-8?q?pallas-txbuilder:=20thread=20auxiliary=5Fda?= =?UTF-8?q?ta=20through=20StagingTransaction=20=E2=86=92=20Conway=20build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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>. 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. --- pallas-txbuilder/src/conway.rs | 85 ++++++++++++++++++- pallas-txbuilder/src/transaction/model.rs | 26 +++++- pallas-txbuilder/src/transaction/serialise.rs | 1 + 3 files changed, 107 insertions(+), 5 deletions(-) diff --git a/pallas-txbuilder/src/conway.rs b/pallas-txbuilder/src/conway.rs index a958078..6bbef86 100644 --- a/pallas-txbuilder/src/conway.rs +++ b/pallas-txbuilder/src/conway.rs @@ -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 = 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()); + } +} diff --git a/pallas-txbuilder/src/transaction/model.rs b/pallas-txbuilder/src/transaction/model.rs index 5e1227e..b56e947 100644 --- a/pallas-txbuilder/src/transaction/model.rs +++ b/pallas-txbuilder/src/transaction/model.rs @@ -40,10 +40,14 @@ pub struct StagingTransaction { pub signature_amount_override: Option, pub change_address: Option
, pub language_view: Option, + /// 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>, // 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) -> 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 diff --git a/pallas-txbuilder/src/transaction/serialise.rs b/pallas-txbuilder/src/transaction/serialise.rs index e60008f..28e0c08 100644 --- a/pallas-txbuilder/src/transaction/serialise.rs +++ b/pallas-txbuilder/src/transaction/serialise.rs @@ -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(); From 51a0d0bd77ab9ef00b4f11e1bcd35450e11bb309 Mon Sep 17 00:00:00 2001 From: Cobb Date: Mon, 4 May 2026 12:33:19 -0700 Subject: [PATCH 2/8] pallas-addresses: pub fn new on StakeAddress --- pallas-addresses/src/lib.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pallas-addresses/src/lib.rs b/pallas-addresses/src/lib.rs index 8ffbbfc..1d14f15 100644 --- a/pallas-addresses/src/lib.rs +++ b/pallas-addresses/src/lib.rs @@ -545,6 +545,11 @@ impl AsRef<[u8]> for StakePayload { } impl StakeAddress { + /// Construct a stake (reward) address from a network + payload. + pub fn new(network: Network, payload: StakePayload) -> Self { + Self(network, payload) + } + /// Gets the network assoaciated with this address pub fn network(&self) -> Network { self.0 From 68221fbcb57ea499502ad23547313ff91a21ba72 Mon Sep 17 00:00:00 2001 From: Cobb Date: Mon, 4 May 2026 12:36:29 -0700 Subject: [PATCH 3/8] =?UTF-8?q?pallas-txbuilder:=20thread=20certificates?= =?UTF-8?q?=20through=20StagingTransaction=20=E2=86=92=20Conway=20build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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>> 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. --- pallas-txbuilder/src/conway.rs | 67 +++++++++++++++++-- pallas-txbuilder/src/transaction/model.rs | 26 ++++++- pallas-txbuilder/src/transaction/serialise.rs | 1 + 3 files changed, 87 insertions(+), 7 deletions(-) diff --git a/pallas-txbuilder/src/conway.rs b/pallas-txbuilder/src/conway.rs index 6bbef86..b63a27e 100644 --- a/pallas-txbuilder/src/conway.rs +++ b/pallas-txbuilder/src/conway.rs @@ -4,10 +4,10 @@ use pallas_codec::utils::CborWrap; use pallas_crypto::hash::Hash; use pallas_primitives::{ conway::{ - AuxiliaryData, DatumOption, ExUnits as PallasExUnits, NativeScript, NetworkId, NonZeroInt, - PlutusData, PlutusScript, PostAlonzoTransactionOutput, PseudoScript as PallasScript, - PseudoTransactionOutput, Redeemer, RedeemerTag, TransactionBody, TransactionInput, Tx, - Value, WitnessSet, + 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, }; @@ -240,8 +240,18 @@ impl BuildConway for StagingTransaction { ttl: self.invalid_from_slot, validity_interval_start: self.valid_from_slot, fee: self.fee.unwrap_or_default(), - certificates: None, // TODO - withdrawals: None, // TODO + certificates: NonEmptySet::from_vec( + self.certificates + .as_deref() + .unwrap_or(&[]) + .iter() + .map(|bytes| { + Certificate::decode_fragment(bytes) + .map_err(|_| TxBuilderError::CorruptedTxBytes) + }) + .collect::, _>>()?, + ), + withdrawals: None, // TODO auxiliary_data_hash: None, // TODO (accept user input) mint, script_data_hash, @@ -439,6 +449,51 @@ mod tests { 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() diff --git a/pallas-txbuilder/src/transaction/model.rs b/pallas-txbuilder/src/transaction/model.rs index b56e947..35264a7 100644 --- a/pallas-txbuilder/src/transaction/model.rs +++ b/pallas-txbuilder/src/transaction/model.rs @@ -45,7 +45,11 @@ pub struct StagingTransaction { /// doesn't implement `Eq`, which the rest of this struct requires. /// `conway::build_conway_raw` decodes on the way out. pub auxiliary_data: Option>, - // 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>>, // pub withdrawals: TODO // pub updates: TODO // pub phase_2_valid: TODO @@ -396,6 +400,26 @@ impl StagingTransaction { self.auxiliary_data = None; 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) -> 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 diff --git a/pallas-txbuilder/src/transaction/serialise.rs b/pallas-txbuilder/src/transaction/serialise.rs index 28e0c08..f1b8bd5 100644 --- a/pallas-txbuilder/src/transaction/serialise.rs +++ b/pallas-txbuilder/src/transaction/serialise.rs @@ -481,6 +481,7 @@ mod tests { script_data_hash: Some(Bytes32([0; 32])), language_view: Some(crate::scriptdata::LanguageView(1, vec![1, 2, 3])), auxiliary_data: None, + certificates: None, }; let serialised_tx = serde_json::to_string(&tx).unwrap(); From 310b0fe5626ec5785f67122531807389ed4d5951 Mon Sep 17 00:00:00 2001 From: Cobb Date: Mon, 4 May 2026 12:50:01 -0700 Subject: [PATCH 4/8] =?UTF-8?q?docs:=20BRANCH-NOTES=20=E2=80=94=20fork=20r?= =?UTF-8?q?ationale,=20what's=20patched,=20upstream=20PR=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit documents the three changes on feat-aux-data: - pallas-txbuilder auxiliary_data plumbing - pallas-txbuilder certificates plumbing - pallas-addresses StakeAddress::new constructor each tied to the upstream TODO it fills, with test additions noted. records that aldabra consumes via [patch.crates-io] across all 7 pallas-* crates so cargo resolves the graph consistently. PR upstream not yet submitted — plan locked in: split into three separate PRs for review ergonomics. --- BRANCH-NOTES.md | 120 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 BRANCH-NOTES.md diff --git a/BRANCH-NOTES.md b/BRANCH-NOTES.md new file mode 100644 index 0000000..7458e8b --- /dev/null +++ b/BRANCH-NOTES.md @@ -0,0 +1,120 @@ +# Sulkta-Coop/pallas — `feat-aux-data` branch notes + +This is a vendored fork of [txpipe/pallas](https://github.com/txpipe/pallas) +based on the `v0.32.1` tag. It exists to fill in three upstream `// TODO` +markers that block real-world Conway-era transaction building. + +The fork is consumed via `[patch.crates-io]` in +[`Sulkta-Coop/aldabra`](http://192.168.0.5:3001/Sulkta-Coop/aldabra)'s +workspace `Cargo.toml`. Once these changes are accepted upstream, the +patch entry gets dropped and aldabra goes back to vanilla crates.io. + +## What's added beyond `v0.32.1` + +### 1. `pallas-txbuilder` — `auxiliary_data` field on `StagingTransaction` + +Upstream had `// pub auxiliary_data: TODO` on the struct and +`auxiliary_data: None.into(), // TODO` in the conway builder. Without +this, **CIP-20 / CIP-25 / CIP-68 metadata can't ride a tx built with +pallas-txbuilder.** + +Added: +- `pub auxiliary_data: Option>` field (opaque CBOR bytes — + `pallas_primitives::AuxiliaryData` doesn't impl `Eq`, which the + rest of `StagingTransaction` requires). +- Builder methods `.auxiliary_data(cbor_bytes)` and + `.clear_auxiliary_data()`. +- `conway::build_conway_raw` now decodes the bytes via + `AuxiliaryData::decode_fragment` and plumbs them in. The existing + `auxiliary_data_hash` computation block (already in upstream) runs + after assignment and populates the body's hash automatically. + +Tests added: +- `auxiliary_data_round_trips_through_build` — encodes a CIP-25 + shape, attaches, builds, decodes, asserts byte-equivalent + re-encode + matching `auxiliary_data_hash`. +- `no_auxiliary_data_means_no_hash` — negative path. + +### 2. `pallas-txbuilder` — `certificates` field on `StagingTransaction` + +Upstream had `// pub certificates: TODO` and +`certificates: None, // TODO` in the conway builder. Without this, +**stake registration / delegation, pool registration, DRep ops, and +Voltaire-era cert types can't be built.** + +Added: +- `pub certificates: Option>>` field (opaque CBOR per + cert, same reason as `auxiliary_data`). +- Builder methods `.add_certificate(cbor_bytes)` and + `.clear_certificates()`. +- `conway::build_conway_raw` decodes each entry via + `Certificate::decode_fragment` and plumbs them into + `TransactionBody.certificates` via `NonEmptySet::from_vec`. + +Tests added: +- `certificates_plumb_through_to_tx_body` — encodes a + `StakeRegistration(AddrKeyhash([7;28]))`, builds, decodes, + asserts the cert round-trips byte-for-byte. +- `no_certificates_means_none` — negative path. + +### 3. `pallas-addresses` — `pub fn new` on `StakeAddress` + +Upstream defined `pub struct StakeAddress(Network, StakePayload)` with +**unexported tuple fields** and no `new()` constructor — so external +callers can't construct one directly. `ShelleyAddress::new` exists +already; this just matches the pattern. + +Without this, computing a wallet's reward (stake) address +required round-tripping through a `ShelleyAddress` and `TryFrom` — +clunky and indirect. + +## How aldabra consumes this + +`Sulkta-Coop/aldabra/Cargo.toml` has: + +```toml +[patch.crates-io] +pallas-codec = { git = "...Sulkta-Coop/pallas.git", branch = "feat-aux-data" } +pallas-crypto = { git = "...Sulkta-Coop/pallas.git", branch = "feat-aux-data" } +pallas-primitives = { git = "...Sulkta-Coop/pallas.git", branch = "feat-aux-data" } +pallas-traverse = { git = "...Sulkta-Coop/pallas.git", branch = "feat-aux-data" } +pallas-addresses = { git = "...Sulkta-Coop/pallas.git", branch = "feat-aux-data" } +pallas-wallet = { git = "...Sulkta-Coop/pallas.git", branch = "feat-aux-data" } +pallas-txbuilder = { git = "...Sulkta-Coop/pallas.git", branch = "feat-aux-data" } +``` + +All seven pallas crates we depend on are patched against the same +commit so cargo's version graph resolves consistently. + +## Upstream PR status + +**Not yet submitted.** Plan: + +1. ✅ Land the changes locally on the fork. +2. ✅ Verify no upstream tests broken (`cargo test --workspace` + passes — confirmed 2026-05-04). +3. ✅ Verify aldabra builds + tests against the fork (88 unit tests + green). +4. ☐ Open PR against `txpipe/pallas` on github.com. +5. ☐ Once upstream merges, drop our `[patch.crates-io]`. + +The PR will probably need to be split into three separate PRs (one +per change) for upstream review ergonomics: + +- `pallas-txbuilder: thread auxiliary_data through StagingTransaction → Conway build` +- `pallas-txbuilder: thread certificates through StagingTransaction → Conway build` +- `pallas-addresses: pub fn new on StakeAddress` + +## Change discipline going forward + +If we add more fork-only patches (e.g. for upcoming withdrawals / +voting_procedures / proposal_procedures TODOs), each goes in a +separate commit on this branch so the eventual upstream PRs are +clean to extract. + +All commits on this branch must: +- Have a self-contained subject explaining what TODO it fills in. +- Pass the full pallas-txbuilder test suite (`cargo test -p pallas-txbuilder`). +- Add at least one positive + one negative test for the new code path. +- Not depend on Sulkta-specific naming or assumptions — these + patches need to be upstream-mergeable as-is. From 507fd9da15f1239ff2df866e0d7601d4518e83a3 Mon Sep 17 00:00:00 2001 From: Kayos Date: Wed, 6 May 2026 07:11:23 -0700 Subject: [PATCH 5/8] =?UTF-8?q?pallas-txbuilder:=20thread=20voting=5Fproce?= =?UTF-8?q?dures=20through=20StagingTransaction=20=E2=86=92=20Conway=20bui?= =?UTF-8?q?ld?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fills the third of pallas-txbuilder's Conway TODOs (after auxiliary_data + certificates): - StagingTransaction gains `voting_procedures: Option>` (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> 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 --- BRANCH-NOTES.md | 28 ++++++++- pallas-txbuilder/src/conway.rs | 72 ++++++++++++++++++++++- pallas-txbuilder/src/transaction/model.rs | 23 ++++++++ 3 files changed, 120 insertions(+), 3 deletions(-) diff --git a/BRANCH-NOTES.md b/BRANCH-NOTES.md index 7458e8b..d638d19 100644 --- a/BRANCH-NOTES.md +++ b/BRANCH-NOTES.md @@ -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>` 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>`), 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 diff --git a/pallas-txbuilder/src/conway.rs b/pallas-txbuilder/src/conway.rs index b63a27e..9d7aab2 100644 --- a/pallas-txbuilder/src/conway.rs +++ b/pallas-txbuilder/src/conway.rs @@ -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() diff --git a/pallas-txbuilder/src/transaction/model.rs b/pallas-txbuilder/src/transaction/model.rs index 35264a7..bc4e4c4 100644 --- a/pallas-txbuilder/src/transaction/model.rs +++ b/pallas-txbuilder/src/transaction/model.rs @@ -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>>, + /// CBOR-encoded `pallas_primitives::conway::VotingProcedures`. A single + /// blob (the type is `NonEmptyKeyValuePairs>`, 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>, // 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>` 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) -> 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 From 8091abd1b45c716453b7360def29311cf4600c0d Mon Sep 17 00:00:00 2001 From: Kayos Date: Wed, 6 May 2026 08:05:55 -0700 Subject: [PATCH 6/8] pallas-txbuilder: voting_procedures rejects empty CBOR maps AUDIT-2026-05-06 M-4 fix from the aldabra Phase 3-6 audit. NonEmptyKeyValuePairs::decode in pallas-codec accepts `0xa0` (empty CBOR map) because the upstream empty-map check is commented out. That decoded value re-encodes correctly and passes through pallas-txbuilder, but the resulting Conway tx fails ledger validation at submit time with a non-obvious error. Add a debug_assert_ne! on the builder method input + clear doc note warning callers to omit the field instead of passing an empty map. Release builds pass through (no overhead); dev/test builds catch accidental empty-map calls with a clear panic message. The pre-existing aldabra build_signed_drep_vote_cast always constructs a non-empty map so it doesn't trip this; the guard is for future callers. --- pallas-txbuilder/src/transaction/model.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/pallas-txbuilder/src/transaction/model.rs b/pallas-txbuilder/src/transaction/model.rs index bc4e4c4..8e20281 100644 --- a/pallas-txbuilder/src/transaction/model.rs +++ b/pallas-txbuilder/src/transaction/model.rs @@ -433,7 +433,22 @@ impl StagingTransaction { /// VotingProcedure>>` map and pass the encoded bytes here. /// /// Used for DRep / SPO / committee voting on Conway governance actions. + /// + /// **Empty-map footgun**: Conway ledger expects this field to be + /// omitted (i.e. `None`) when there are no votes. Passing an empty + /// CBOR map (`0xa0`) gets through pallas-codec's + /// `NonEmptyKeyValuePairs::decode` (the upstream empty-map check is + /// commented out) but the resulting tx fails ledger validation at + /// submit time. **Don't call this builder if the map is empty — + /// just omit it.** The debug_assert below catches accidental empty- + /// map calls in dev/test builds. pub fn voting_procedures(mut self, cbor_bytes: Vec) -> Self { + debug_assert_ne!( + cbor_bytes.as_slice(), + &[0xa0u8], + "voting_procedures called with empty CBOR map — Conway ledger \ + rejects this; omit the field instead", + ); self.voting_procedures = Some(cbor_bytes); self } From 36adf8fe8cb73fad29921354a49d0becf7d71045 Mon Sep 17 00:00:00 2001 From: Cobb Hayes Date: Wed, 27 May 2026 11:31:18 -0700 Subject: [PATCH 7/8] Public-flip audit: scrub residual LAN URL from fork notes --- BRANCH-NOTES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BRANCH-NOTES.md b/BRANCH-NOTES.md index d638d19..ce9692c 100644 --- a/BRANCH-NOTES.md +++ b/BRANCH-NOTES.md @@ -5,7 +5,7 @@ based on the `v0.32.1` tag. It exists to fill in three upstream `// TODO` markers that block real-world Conway-era transaction building. The fork is consumed via `[patch.crates-io]` in -[`Sulkta-Coop/aldabra`](http://192.168.0.5:3001/Sulkta-Coop/aldabra)'s +[`Sulkta-Coop/aldabra`](https://git.sulkta.com/Sulkta-Coop/aldabra)'s workspace `Cargo.toml`. Once these changes are accepted upstream, the patch entry gets dropped and aldabra goes back to vanilla crates.io. From 383cde923a045002b2e209e7012bd155547df8b3 Mon Sep 17 00:00:00 2001 From: kayos Date: Wed, 27 May 2026 22:14:55 -0700 Subject: [PATCH 8/8] ci: add gitleaks workflow (Sulkta canonical) --- .forgejo/workflows/gitleaks.yml | 40 +++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .forgejo/workflows/gitleaks.yml diff --git a/.forgejo/workflows/gitleaks.yml b/.forgejo/workflows/gitleaks.yml new file mode 100644 index 0000000..10d7847 --- /dev/null +++ b/.forgejo/workflows/gitleaks.yml @@ -0,0 +1,40 @@ +# .forgejo/workflows/gitleaks.yml +# +# Sulkta canonical gitleaks workflow. Drop a copy into every public repo at +# `.forgejo/workflows/gitleaks.yml` after the Forgejo act_runner is registered +# (task #295). +# +# Pairs with the pre-receive hook installed on every bare repo — that one is +# the strict enforcement layer (rejects the push); this one provides the +# per-PR red ✗ that branch-protection rules can require before merge. +# +# Layer 1 (this workflow): visible per-PR status, can be a required check. +# Layer 2 (pre-receive hook): strict enforcement at the server. +# Layer 3 (johnny5 cron sweep): nightly full-history sweep across all repos. + +name: gitleaks + +on: + push: + pull_request: + +jobs: + scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # Full history — gitleaks needs depth to scan a commit range. + fetch-depth: 0 + + - name: install gitleaks + run: | + curl -sSL -o gl.tar.gz \ + https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz + tar xzf gl.tar.gz gitleaks + chmod +x gitleaks + ./gitleaks version + + - name: scan + run: | + ./gitleaks detect --source . --no-banner --redact --verbose