Skip to content

feat(cardano): add Dijkstra sub transactions and ledger fields - #215

Open
geofflittle wants to merge 7 commits into
utxorpc:mainfrom
geofflittle:feat/cardano-dijkstra-fields-v1
Open

geofflittle wants to merge 7 commits into
utxorpc:mainfrom
geofflittle:feat/cardano-dijkstra-fields-v1

Conversation

@geofflittle

@geofflittle geofflittle commented Oct 5, 2026 •

Copy link
Copy Markdown

What?

The fields that top level and sub transactions share move into a new TxCommon, which Tx holds as common beside its top level only fields collateral, fee, successful, sub_transactions and starting_account_balance_intervals. Each sub transaction is a TxCommon, so it has none of the fields a ledger sub transaction lacks and cannot hold sub transactions of its own. TxCommon gains direct_deposits, account_balance_intervals, guards and required_top_level_guards, and five older body keys Tx had no field for (auxiliary_data_hash, script_data_hash, network_id, treasury_value, treasury_donation). Tx also had no field for body key 14, the required signers before Dijkstra and the guards from Dijkstra, and guards is that key. TxPattern matches a Tx and the TxCommon of each of its sub transactions, and ReadTxRequest.hash also accepts a sub transaction hash.

BlockHeader gains endorser_block_announcement and body_contains_leios_certificate, and BlockBody gains leios_certificate and peras_certificate. NativeScript gains script_require_guard, RedeemerPurpose gains REDEEMER_PURPOSE_GUARDING, and PoolRegistrationCert and PoolStakeShare gain a PoolBlsKey. PParams gains the Dijkstra parameters as fields 32 to 54 and Genesis as fields 43 to 65. A parameter change proposal is now a PParamsUpdate, where an absent field leaves the parameter unchanged.

Why?

A client of a Dijkstra node needs every value the ledger produces, and these messages had no field for the values Dijkstra adds. Without sub_transactions a client misses the outputs sub transactions create and the inputs they spend, so its UTxO set is wrong.

In PParams a plain scalar set to zero reads as unset, and an absent max_pledge_leverage means no limit, so a proposal that sets a zero or removes the limit reads as no change. ParameterChangeAction.protocol_param_update is now typed PParamsUpdate, so code generated from the unreleased v1 that reads it as PParams must change. Tx moves its shared fields under common, so the same code reads tx.common.inputs where it read tx.inputs.

Testing done

buf lint reports the same 28 findings at this head as on main. Against main, buf breaking reports the presence change on TxValidity.start and ttl, the type change of protocol_param_update, and 31 lines on Tx for fields 1 to 6 changing name and type and fields 7 to 15 deleted. Under the WIRE rules it reports the type change and 19 lines on Tx. Against the Dijkstra CDDL at IntersectMBO/cardano-ledger@845f131, the fields of Tx with common expanded are the transaction_body keys, and the fields of TxCommon are the sub_transaction_body keys. With buf 1.74.0:

$ buf lint proto | wc -l
      28
$ buf lint '.git#branch=origin/main,subdir=proto' | wc -l
      28
$ buf breaking proto --against '.git#branch=origin/main,subdir=proto' | wc -l
      34
$ buf breaking proto --against '.git#branch=origin/main,subdir=proto' --config '{"version":"v1","breaking":{"use":["WIRE"]}}' | wc -l
      20

Notes

  • The Dijkstra shapes can change before merge if the ledger changes them. BlockBody.peras_certificate is the raw certificate bytes, empty when the block has none, because the ledger encodes the certificate as a bytes placeholder and refuses any certificate until Peras activates. If [DRAFT] Use peras types from cardano-base in ledger IntersectMBO/cardano-ledger#6164 defines the certificate structure before this PR merges, field 3 becomes a message. Otherwise a message field is added beside the bytes once the ledger defines the type, since buf breaking flags a bytes to message change under WIRE and WIRE_JSON. There is no Leios committee query because the node serves none.
  • A block with a Leios certificate has no transactions of its own. Its body.tx lists the transactions of the endorser block its certificate certifies, as a Dijkstra node serves the block over local chain sync.
  • PoolStakeShare.bls_key is absent when the pool had no BLS key in the stake snapshot, even if it has registered one since.
  • The fields are added to v1 only.
  • TxValidity.start and ttl are now optional, so a bound of 0 differs from an absent bound. The generated code changes and the wire format does not.
  • ReadTx with a sub transaction hash returns the last transaction that includes it. A sub transaction can appear in several transactions on chain, and only the last can have applied it, because the earlier ones failed phase 2 validation.

- Tx: sub transactions, deposits, balance intervals
- Tx: guards and required top level guards
- scripts: guard clause and guarding redeemer purpose
- PoolRegistrationCert BLS key and Dijkstra PParams
- v1beta cardano.proto: the fields added to v1
- same message names, field names and numbers
- v1beta/cardano.proto: restored to main, the fields stay in v1
Comment thread proto/utxorpc/v1/cardano/cardano.proto Outdated
uint64 max_endorser_block_references_size = 43; // The maximum endorser block references size.
uint64 max_endorser_block_txs_size = 44; // The maximum endorser block transactions size.
ExUnits max_endorser_block_execution_units = 45; // The maximum execution units per endorser block.
uint64 max_ref_script_size_per_endorser_block = 46; // The maximum reference script size per endorser block.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The six Peras parameters and the two reference input fees are now PParams fields 47 to 54 and Genesis fields 58 to 65, so PParams has a field for each entry of the record you linked.

- v1/cardano.proto: PParams fields 47 to 54 from the Dijkstra ledger
- slot interval is uint64, as the epoch interval parameters
- bootstrap round Word32 is optional uint64, as the Word32 sizes

@carbolymer carbolymer left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work.
However, this PR makes Tx recursive, which does not match the CDDL: a sub transaction could then have its own sub transactions, which the ledger does not allow.
I suggest a common message type with the fields shared by top-level and sub transactions - the comment on sub_transactions has a draft.

Comment thread proto/utxorpc/v1/cardano/cardano.proto Outdated
bytes hash = 13; // Hash of the transaction that serves as main identifier
repeated GovernanceActionProposal proposals = 14; // List of governance actions proposed
repeated VoterVotes votes = 15; // List of voters and their corresponding votes cast in this transaction
repeated Tx sub_transactions = 16; // List of sub transactions, in body order

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tx also cannot carry several transaction body fields, and in Dijkstra sub transactions carry them too.
That gap predates this PR, so I opened an issue for it:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added to TxCommon.

Comment thread proto/utxorpc/v1/cardano/cardano.proto Outdated
Comment on lines +114 to +119
repeated Tx sub_transactions = 16; // List of sub transactions, in body order
repeated DirectDeposit direct_deposits = 17; // List of deposits into reward accounts
repeated AccountBalanceInterval account_balance_intervals = 18; // List of account balance intervals
repeated AccountBalanceInterval starting_account_balance_intervals = 19; // List of starting account balance intervals
repeated StakeCredential guards = 20; // List of credentials in body key 14, the required signers from Alonzo and the guards from Dijkstra
repeated RequiredTopLevelGuard required_top_level_guards = 21; // List of credentials the top level transaction must hold in its guards

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reusing Tx for sub transactions lets a sub transaction carry fields that only a top-level transaction has: fee, collateral, successful, sub_transactions and starting_account_balance_intervals.
successful is the risky one, because it reads as false on every sub transaction unless the server copies the top-level flag.
A ledger sub transaction has none of these fields, and cardano-api keeps the shared fields in one type with the top-level-only ones apart:

I suggest moving the shared fields into their own message:

// The part of a transaction that top-level and sub transactions share.
message TxCommon {
  repeated TxInput inputs = 1; // Resolved against the UTxO before the batch.
  repeated TxOutput outputs = 2; // Referenced by this transaction's own hash and their index.
  repeated Certificate certificates = 3;
  repeated Withdrawal withdrawals = 4;
  repeated Multiasset mint = 5;
  repeated TxInput reference_inputs = 6; // Resolved against the UTxO before the batch.
  WitnessSet witnesses = 7;
  optional TxValidity validity = 8;
  optional AuxData auxiliary = 9;
  bytes hash = 10; // Hash of this transaction's own body.
  repeated GovernanceActionProposal proposals = 11;
  repeated VoterVotes votes = 12;
  repeated StakeCredential guards = 13; // Required signers before Dijkstra, guards from Dijkstra.
  repeated RequiredTopLevelGuard required_top_level_guards = 14;
  repeated DirectDeposit direct_deposits = 15;
  repeated AccountBalanceInterval account_balance_intervals = 16; // Checked against the balances this transaction sees.
}

message Tx {
  TxCommon common = 1;
  optional Collateral collateral = 2; // Covers every script of the batch.
  BigInt fee = 3; // Pays for the whole batch.
  bool successful = 4; // When false, only the collateral is collected and no sub transaction is applied.
  repeated TxCommon sub_transactions = 5; // In body order; sub transactions do not nest.
  repeated AccountBalanceInterval starting_account_balance_intervals = 6; // Checked against the balances before the batch.
}

Fields marked optional are optional in the ledger, and the repeated ones are empty when absent.
TxValidity.start and TxValidity.ttl are plain uint64, so an unset bound reads as 0, and they should be optional too.
Clients and field masks would then address the shared fields through common, and TxPattern would match against common of the transaction and of each sub transaction.
The fields from #218 would also go in TxCommon.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. The shared fields are in TxCommon, which Tx holds as common, and each sub transaction is a TxCommon. TxValidity fields are optional too.

Comment thread proto/utxorpc/v1/cardano/cardano.proto Outdated
repeated bytes pool_owners = 7; // List of pool owner key hashes.
repeated Relay relays = 8; // List of relays.
PoolMetadata pool_metadata = 9; // Pool metadata.
PoolBlsKey bls_key = 10; // Pool BLS key.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ledger has bls_key as optional, so I suggest declaring it optional PoolBlsKey bls_key = 10;, as TxInput.redeemer and TxOutput.datum are.
The wire format stays the same, because a message field already has presence.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Comment thread proto/utxorpc/v1/cardano/cardano.proto Outdated
Comment on lines +755 to +777
uint64 max_ref_script_size_per_block = 32; // The maximum reference script size per block.
uint64 max_ref_script_size_per_tx = 33; // The maximum reference script size per transaction.
uint64 ref_script_cost_stride = 34; // The reference script cost stride.
RationalNumber ref_script_cost_multiplier = 35; // The reference script cost multiplier.
RationalNumber max_pledge_leverage = 36; // The maximum pledge leverage.
RationalNumber min_pool_margin = 37; // The minimum pool margin.
uint64 leios_announcement_period_length = 38; // The Leios announcement period length in milliseconds.
uint64 leios_vote_period_length = 39; // The Leios vote period length in milliseconds.
uint64 leios_diffusion_period_length = 40; // The Leios diffusion period length in milliseconds.
uint32 leios_committee_size = 41; // The Leios committee size.
RationalNumber leios_quorum_stake_threshold = 42; // The Leios quorum stake threshold.
uint64 max_endorser_block_references_size = 43; // The maximum endorser block references size.
uint64 max_endorser_block_txs_size = 44; // The maximum endorser block transactions size.
ExUnits max_endorser_block_execution_units = 45; // The maximum execution units per endorser block.
uint64 max_ref_script_size_per_endorser_block = 46; // The maximum reference script size per endorser block.
uint64 peras_min_candidate_block_age = 47; // The Peras minimum candidate block age in slots.
RationalNumber peras_healing_factor = 48; // The Peras healing factor for the cooldown length.
uint32 peras_cert_boost = 49; // The Peras chain weight boost per certificate.
uint32 peras_target_committee_size = 50; // The Peras target committee size.
optional uint64 peras_bootstrap_round = 51; // The Peras round that bootstraps voting, if set.
RationalNumber peras_quorum_threshold_safety_margin = 52; // The Peras quorum threshold safety margin.
BigInt ref_inputs_cost_per_multi_asset_policy = 53; // The fee per multi-asset policy in reference inputs.
BigInt ref_inputs_cost_per_datum_byte = 54; // The fee per datum byte in reference inputs.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These new scalar fields have no presence, so an unset value reads as 0: max_ref_script_size_per_block, max_ref_script_size_per_tx, ref_script_cost_stride, leios_announcement_period_length, leios_vote_period_length, leios_diffusion_period_length, leios_committee_size, max_endorser_block_references_size, max_endorser_block_txs_size, max_ref_script_size_per_endorser_block, peras_min_candidate_block_age, peras_cert_boost and peras_target_committee_size.
PParams also carries partial updates in ParameterChangeAction and the parameters of earlier eras in EraSummary, where these fields are absent, not 0.
I suggest marking them optional, as peras_bootstrap_round already is.
The older scalar fields have the same problem, but that predates this PR.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Updates now use a separate PParamsUpdate.

- Leios and Peras block data, pool BLS keys, Dijkstra genesis
- PParamsUpdate as the type of parameter change updates
- SubTx, five missing tx body keys, presence on optional fields
- ReadTx by a sub transaction hash
- cardano.proto: add TxCommon with the fields both tx levels share
- Tx holds common, the top level only fields and TxCommon sub txs
- drop SubTx and point the TxPattern and Asset docs at TxCommon
- cardano.proto: remove Block.endorser_block and its two messages
- BlockBody.tx: document the certified endorser block transactions
// Request to get a transaction by hash
message ReadTxRequest {
bytes hash = 1; // The hash of the transaction.
bytes hash = 1; // The hash of a transaction, or of a sub transaction to read the last transaction that includes it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a sub transaction hash, this returns the top-level transaction that includes it, because a sub transaction is never on chain by itself; the sub transaction is the entry of sub_transactions with that hash. "The last transaction" needs spelling out, because one sub transaction can be in several top-level transactions: when a batch fails phase 2, none of its sub transactions take effect, so their inputs stay unspent and the same sub transaction can be included again in a later batch.

I suggest saying that the response is the most recent such top-level transaction on the canonical chain, and that it can be unsuccessful. A successful inclusion is always the last one, because it spends the inputs, so the returned transaction's successful flag tells the client whether the sub transaction has taken effect.

Comment on lines +123 to +130
message Tx {
TxCommon common = 1; // Fields the transaction shares with its sub transactions
optional Collateral collateral = 2; // Collateral details in case of failed transaction
BigInt fee = 3; // Transaction fee in ADA
bool successful = 4; // Flag indicating whether the transaction was successful
repeated TxCommon sub_transactions = 5; // List of sub transactions, in body order
repeated AccountBalanceInterval starting_account_balance_intervals = 6; // List of starting account balance intervals
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The structure looks good now, but the field comments still describe a single transaction. A client applying a block needs the batch rules (a batch is a top-level transaction and its sub transactions), so I suggest stating them on the fields:

  • TxCommon.inputs and reference_inputs resolve against the UTxO before the batch, and spending inputs are disjoint across the batch.
  • TxCommon.outputs are referenced by the hash of the TxCommon that holds them, also in a sub transaction.
  • fee and collateral cover the whole batch.
  • When successful is false, only collateral processing applies, including any collateral return; no ordinary effects of the top-level transaction or its sub transactions are applied.
  • starting_account_balance_intervals are checked against the balances before the batch. Each transaction's account_balance_intervals are checked after earlier sub transactions, but before its own withdrawals, certificates and deposits. The top-level transaction's check follows all sub transactions.

// Contains the transactions and the Leios and Peras certificates of a block.
message BlockBody {
repeated Tx tx = 1; // List of transactions.
repeated Tx tx = 1; // List of transactions. For a block with a Leios certificate, the transactions of the certified endorser block.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a block with a Leios certificate, the transactions in tx come from the endorser block announced by the previous block. The ledger validates them at the announcing block's slot, against the ledger state that block left, not at this block's slot. A transaction here is therefore valid even if its TTL is earlier than this block's slot. I suggest saying this in the comment on tx, so that clients do not treat such transactions as expired.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cardano Tx cannot carry network id, script data hash, auxiliary data hash, treasury value and donation

3 participants