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 diff --git a/BRANCH-NOTES.md b/BRANCH-NOTES.md new file mode 100644 index 0000000..ce9692c --- /dev/null +++ b/BRANCH-NOTES.md @@ -0,0 +1,146 @@ +# 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`](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. + +## 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-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 +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-txbuilder: thread voting_procedures 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. 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 diff --git a/pallas-txbuilder/src/conway.rs b/pallas-txbuilder/src/conway.rs index a958078..9d7aab2 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::{ - 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, VotingProcedures, 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, @@ -251,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 @@ -270,8 +287,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 +398,180 @@ 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 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 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() + .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..8e20281 100644 --- a/pallas-txbuilder/src/transaction/model.rs +++ b/pallas-txbuilder/src/transaction/model.rs @@ -40,10 +40,24 @@ pub struct StagingTransaction { pub signature_amount_override: Option, pub change_address: Option
, pub language_view: Option, - // pub certificates: TODO + /// 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>, + /// 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>>, + /// 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 auxiliary_data: TODO // pub phase_2_valid: TODO } @@ -372,6 +386,78 @@ 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 + } + + /// 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 + } + + /// 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. + /// + /// **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 + } + + /// 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 diff --git a/pallas-txbuilder/src/transaction/serialise.rs b/pallas-txbuilder/src/transaction/serialise.rs index e60008f..f1b8bd5 100644 --- a/pallas-txbuilder/src/transaction/serialise.rs +++ b/pallas-txbuilder/src/transaction/serialise.rs @@ -480,6 +480,8 @@ 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, + certificates: None, }; let serialised_tx = serde_json::to_string(&tx).unwrap();