A Conway-era Cardano transaction IR shaped as a dependency graph, with serde round-tripping.
txir models a transaction as a typed tree where each node carries exactly the data needed to construct it. The variant is the documentation: a SimpleSpend input depends only on a UTxO reference; a Plutus spend additionally carries the resolved output, script source, datum source, redeemer, and execution units — all as typed fields, with no out-of-band registries mapping redeemers to inputs by index.
The whole tree is Serialize + Deserialize, so the IR doubles as a stable interchange format between builders, validators, snapshot tests, and non-Rust tooling.
use txir::*;
let tx = TxIR::new()
// simple pubkey spend — children: tx_hash + output_index
.add_input(InputNode {
utxo: UTxORef {
tx_hash: TxHash(Hex([0xab; 32])),
output_index: 0,
},
resolved: None,
witness: WitnessRequirement::Key,
})
// plutus spend with reference script
.add_input(InputNode {
utxo: UTxORef {
tx_hash: TxHash(Hex([0xcd; 32])),
output_index: 1,
},
resolved: None, // builder will resolve from chain
witness: WitnessRequirement::Plutus(PlutusWitness {
script: ScriptSource::Reference {
input: UTxORef {
tx_hash: TxHash(Hex([0xef; 32])),
output_index: 0,
},
script_hash: ScriptHash(Hex([0x11; 28])),
body: None,
},
redeemer: Redeemer {
data: PlutusData::Constr { tag: 0, fields: vec![] },
ex_units: None, // builder will evaluate
},
datum: Some(DatumSource::Inline),
}),
})
.with_fee(FeeNode::Auto);
let json = serde_json::to_string_pretty(&tx)?;
let back: TxIR = serde_json::from_str(&json)?;Conway era, end to end:
- Inputs, reference inputs, outputs
- Collateral, collateral return, total collateral
- Mint with native or Plutus witnesses
- Withdrawals
- Full certificate lineup: stake lifecycle, pool lifecycle, vote delegation, combined Conway certs, DRep registration / update / deregistration, constitutional committee auth / resignation
- Governance proposals (parameter change, hard fork, treasury withdrawals, no confidence, update committee, new constitution, info) and votes
- Treasury donation and current treasury value
- Validity intervals, required signers, network ID, transaction metadata
Each node holds the data it depends on directly, so a builder walking the tree always knows what to do at every step. There are no side tables associating redeemers with inputs by ordinal, no implicit script lookups, no "you must also remember to add X to the witness set." If a Plutus action needs a redeemer, it's a field on that action.
This also means cross-references are explicit: a ScriptSource::Reference names the UTxORef it depends on. A validate() pass can walk the tree, collect every reference, and check that each is present in tx.reference_inputs.
Leaves the builder may want to fill in later use Option or Auto variants rather than a custom wrapper:
| Field | Deferred form | Meaning |
|---|---|---|
InputNode.resolved |
None |
Builder resolves from chain |
ScriptSource::Reference.body |
None |
Builder fetches at fee-calc time |
DatumSource::Reference.body |
None |
Same |
Redeemer.ex_units |
None |
Builder evaluates via Plutus VM |
FeeNode |
Auto |
Builder computes from size + ex-units |
TotalCollateralNode |
Auto |
Builder computes from collateral inputs |
So the same IR can describe a fully-specified transaction or a sketch waiting on chain state.
One WitnessRequirement enum covers inputs, mints, withdrawals, certs, and votes — Key, NativeScript, and Plutus cases. Not every variant is meaningful in every context (mints can't be Key, votes don't carry a datum), and the types don't enforce that — a validate() pass should. The trade buys substantial deduplication for a small, well-documented invariant surface.
Pre-release. The shape is stable, but field additions are likely as Cardano evolves (e.g. ProtocolParamUpdate is intentionally thin). Breaking changes will follow semver.
(your choice)