Compare commits

..

No commits in common. "feat-aux-data" and "sulkta-main" have entirely different histories.

6 changed files with 11 additions and 490 deletions

View file

@ -1,40 +0,0 @@
# .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

View file

@ -1,146 +0,0 @@
# 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<Vec<u8>>` 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<Vec<Vec<u8>>>` 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<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
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.

View file

@ -545,11 +545,6 @@ 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

View file

@ -4,10 +4,10 @@ use pallas_codec::utils::CborWrap;
use pallas_crypto::hash::Hash;
use pallas_primitives::{
conway::{
AuxiliaryData, Certificate, DatumOption, ExUnits as PallasExUnits, NativeScript, NetworkId,
NonZeroInt, PlutusData, PlutusScript, PostAlonzoTransactionOutput,
PseudoScript as PallasScript, PseudoTransactionOutput, Redeemer, RedeemerTag,
TransactionBody, TransactionInput, Tx, Value, VotingProcedures, WitnessSet,
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,18 +240,8 @@ impl BuildConway for StagingTransaction {
ttl: self.invalid_from_slot,
validity_interval_start: self.valid_from_slot,
fee: self.fee.unwrap_or_default(),
certificates: NonEmptySet::from_vec(
self.certificates
.as_deref()
.unwrap_or(&[])
.iter()
.map(|bytes| {
Certificate::decode_fragment(bytes)
.map_err(|_| TxBuilderError::CorruptedTxBytes)
})
.collect::<Result<Vec<_>, _>>()?,
),
withdrawals: None, // TODO
certificates: None, // TODO
withdrawals: None, // TODO
auxiliary_data_hash: None, // TODO (accept user input)
mint,
script_data_hash,
@ -261,14 +251,7 @@ impl BuildConway for StagingTransaction {
collateral_return,
reference_inputs,
total_collateral: None, // TODO
voting_procedures: self
.voting_procedures
.as_deref()
.map(|bytes| {
VotingProcedures::decode_fragment(bytes)
.map_err(|_| TxBuilderError::CorruptedTxBytes)
})
.transpose()?,
voting_procedures: None, // TODO
proposal_procedures: None, // TODO
treasury_value: None, // TODO
donation: None, // TODO
@ -287,14 +270,8 @@ impl BuildConway for StagingTransaction {
Some(witness_set_redeemers)
},
},
success: true, // TODO
auxiliary_data: self
.auxiliary_data
.as_deref()
.map(AuxiliaryData::decode_fragment)
.transpose()
.map_err(|_| TxBuilderError::CorruptedTxBytes)?
.into(),
success: true, // TODO
auxiliary_data: None.into(), // TODO
};
// TODO: pallas auxiliary_data_hash should be Hash<32> not Bytes
@ -398,180 +375,3 @@ 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 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());
}
}

View file

@ -40,24 +40,10 @@ 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>>,
/// 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<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 certificates: TODO
// pub withdrawals: TODO
// pub updates: TODO
// pub auxiliary_data: TODO
// pub phase_2_valid: TODO
}
@ -386,78 +372,6 @@ 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
}
/// 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<u8>) -> 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<Voter, NonEmptyKeyValuePairs<GovActionId,
/// 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<u8>) -> 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

View file

@ -480,8 +480,6 @@ 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();