feat(wallet): reference-script + extras on payment outputs

Adds Babbage/Conway-era reference-script attachment to wallet_send
and wallet_send_unsigned. The output can now carry any combination
of {native assets, inline datum, reference script}.

Why: deploying Plutus validators / minting policies as on-chain
reference UTxOs is the standard Cardano dApp pattern (Agora, Liqwid,
SundaeSwap all do this). Without it every spend or mint that uses
a script has to inline-witness the full CBOR — kilobytes per tx and
quadratic with tx size. Reference scripts let downstream txs witness
via `read_only_input` for ~32 bytes overhead.

API surface:

aldabra-core:
- new `ReferenceScriptSpec<'a> { kind: ScriptKind, cbor: &'a [u8] }`
- `ScriptKind` re-exported from pallas_txbuilder (PlutusV1/V2/V3/Native)
- new `build_signed_payment_extras(...)` and
  `build_unsigned_payment_extras(...)` — supersets of the existing
  `_with_assets` functions; take both `to_inline_datum_cbor` and
  `to_reference_script` Options
- existing `_with_assets` functions kept as thin wrappers that pass
  None for ref script — backwards compatible
- internal `output_with_assets`, `prepare_payment`, and
  `build_staging_with_fee` thread the new ref-script Option through

aldabra-mcp:
- `SendArgs` and `UnsignedSendArgs` gain
  `reference_script_cbor_hex: Option<String>` and
  `reference_script_kind: Option<String>`
- Both must be set or both omitted; mismatched returns a clean error
- `parse_script_kind` helper — case-insensitive, accepts
  PlutusV1/V2/V3/Native (plus shortcut V1/V2/V3)

Reference scripts are intentionally never attached to change outputs.
The change goes back to the wallet's own address, where a script
attachment would lock value into a publicized script that we'd then
have to spend BACK out — pointless. Ref-script attachment is only
on the recipient (`to`) output.

This unblocks Track B-fast step 3 of the preprod DAO bringup —
deploying the 11 Agora script bytecodes (governor / stakes /
proposal / treasury / mutate / noOp / treasuryWithdrawal validators
+ GST / StakeST / ProposalST / GAT minting policies) as reference
UTxOs against Sulkta's preprod wallet.

No new tests in this commit — Phase 2 (Plutus-policy mint with
custom output) lands in a follow-up that will exercise this path
end-to-end against a real chain submit on preprod.
This commit is contained in:
Sulkta 2026-05-07 06:17:07 -07:00
parent 1500a30e9d
commit 8b0e090668
3 changed files with 201 additions and 12 deletions

View file

@ -69,10 +69,12 @@ pub use governance::{
DRepTarget, VoteChoice, DREP_REGISTRATION_DEPOSIT_LOVELACE,
};
pub use tx::{
build_signed_payment, build_signed_payment_with_assets, build_unsigned_payment,
build_unsigned_payment_with_assets, hex_decode, AssetSpec, InputUtxo, PaymentSummary,
ProtocolParams, UnsignedPayment,
build_signed_payment, build_signed_payment_extras, build_signed_payment_with_assets,
build_unsigned_payment, build_unsigned_payment_extras, build_unsigned_payment_with_assets,
hex_decode, AssetSpec, InputUtxo, PaymentSummary, ProtocolParams, ReferenceScriptSpec,
UnsignedPayment,
};
pub use pallas_txbuilder::ScriptKind;
#[derive(Debug, Error)]
pub enum WalletError {

View file

@ -45,7 +45,19 @@
use ed25519_bip32::XPrv;
use pallas_addresses::Address as PallasAddress;
use pallas_crypto::key::ed25519::SecretKeyExtended;
use pallas_txbuilder::{BuildConway, BuiltTransaction, Input, Output, StagingTransaction};
use pallas_txbuilder::{BuildConway, BuiltTransaction, Input, Output, ScriptKind, StagingTransaction};
/// Reference-script attached to a tx output. Used to deploy Plutus
/// validators / minting policies as reusable on-chain references so
/// downstream txs can spend from / mint under those scripts via
/// `--tx-in-script-file ref` semantics instead of inline-witnessing
/// the entire CBOR every time. Each ref-script carries its language
/// (PlutusV1/V2/V3 — Native is also valid but rare).
#[derive(Debug, Clone, Copy)]
pub struct ReferenceScriptSpec<'a> {
pub kind: ScriptKind,
pub cbor: &'a [u8],
}
use pallas_wallet::PrivateKey;
use serde::{Deserialize, Serialize};
use zeroize::Zeroize;
@ -355,6 +367,7 @@ fn output_with_assets(
lovelace: u64,
assets: &std::collections::BTreeMap<String, u64>,
inline_datum_cbor: Option<&[u8]>,
reference_script: Option<ReferenceScriptSpec<'_>>,
) -> Result<Output, WalletError> {
let mut out = Output::new(addr.clone(), lovelace);
for (key, qty) in assets {
@ -376,6 +389,15 @@ fn output_with_assets(
if let Some(datum) = inline_datum_cbor {
out = out.set_inline_datum(datum.to_vec());
}
// 2026-05-07: optional reference-script attached to the output.
// This is the on-chain equivalent of `cardano-cli ... --tx-out
// --tx-out-reference-script-file ...`. Once deployed, downstream
// txs can witness the script via `read_only_input` instead of
// inline-witnessing the full CBOR. Required for any DAO/dApp that
// wants to keep witness sizes manageable when validators are large.
if let Some(rs) = reference_script {
out = out.set_inline_script(rs.kind, rs.cbor.to_vec());
}
Ok(out)
}
@ -386,6 +408,7 @@ fn build_staging_with_fee(
to_lovelace: u64,
to_assets: &std::collections::BTreeMap<String, u64>,
to_inline_datum_cbor: Option<&[u8]>,
to_reference_script: Option<ReferenceScriptSpec<'_>>,
change_addr: &PallasAddress,
change_lovelace: u64,
change_assets: &std::collections::BTreeMap<String, u64>,
@ -402,6 +425,7 @@ fn build_staging_with_fee(
to_lovelace,
to_assets,
to_inline_datum_cbor,
to_reference_script,
)?);
let nonzero_change_assets: std::collections::BTreeMap<String, u64> = change_assets
.iter()
@ -409,13 +433,15 @@ fn build_staging_with_fee(
.map(|(k, v)| (k.clone(), *v))
.collect();
if change_lovelace > 0 || !nonzero_change_assets.is_empty() {
// Change output never carries an inline datum — it goes back to
// the wallet, which has no validator to satisfy.
// Change output never carries an inline datum or reference
// script — it goes back to the wallet, which has no validator
// to satisfy and no reason to publish a script there.
staging = staging.output(output_with_assets(
change_addr,
change_lovelace,
&nonzero_change_assets,
None,
None,
)?);
}
staging = staging.fee(fee).network_id(network_id);
@ -482,6 +508,13 @@ fn build_unsigned_bytes(
/// script address with a datum the validator can read (AUDIT4-3
/// fix). Change output never gets a datum — it goes back to the
/// wallet which has no validator to satisfy.
///
/// `to_reference_script`, when `Some`, attaches the script bytes as
/// a reference-script on the recipient output (Babbage/Conway era
/// `--tx-out-reference-script-file`). Used to deploy a Plutus
/// validator/policy as a reusable on-chain reference. Pairs naturally
/// with sending to the wallet's own address — the wallet then "owns"
/// (spends from) the ref-script UTxO any time it wants to retire it.
#[allow(clippy::too_many_arguments)]
fn prepare_payment(
network: Network,
@ -491,6 +524,7 @@ fn prepare_payment(
lovelace: u64,
assets_to_send: &[AssetSpec],
to_inline_datum_cbor: Option<&[u8]>,
to_reference_script: Option<ReferenceScriptSpec<'_>>,
params: &ProtocolParams,
) -> Result<(BuiltTransaction, PaymentSummary), WalletError> {
let to_addr = parse_address(to_address_bech32)?;
@ -579,6 +613,7 @@ fn prepare_payment(
lovelace,
&target_assets,
to_inline_datum_cbor,
to_reference_script,
&change_addr,
change_pass1,
&change_assets,
@ -630,6 +665,7 @@ fn prepare_payment(
lovelace,
&target_assets,
to_inline_datum_cbor,
to_reference_script,
&change_addr,
final_change,
&change_assets,
@ -751,6 +787,42 @@ pub fn build_signed_payment_with_assets(
assets_to_send: &[AssetSpec],
to_inline_datum_cbor: Option<&[u8]>,
params: &ProtocolParams,
) -> Result<Vec<u8>, WalletError> {
build_signed_payment_extras(
payment_key,
network,
available_utxos,
change_address_bech32,
to_address_bech32,
lovelace,
assets_to_send,
to_inline_datum_cbor,
None,
params,
)
}
/// Build + sign a Conway-era payment with the full output extras —
/// ADA + native assets + optional inline datum + optional reference
/// script. Public superset of `build_signed_payment_with_assets`.
///
/// `to_reference_script`: when `Some`, attaches the script CBOR as a
/// reference-script on the recipient output (Babbage/Conway era).
/// Used to deploy a Plutus validator/policy as a reusable on-chain
/// reference. Pair with sending to the wallet's own address so the
/// wallet retains the ability to retire the deployment later.
#[allow(clippy::too_many_arguments)]
pub fn build_signed_payment_extras(
payment_key: &PaymentKey,
network: Network,
available_utxos: &[InputUtxo],
change_address_bech32: &str,
to_address_bech32: &str,
lovelace: u64,
assets_to_send: &[AssetSpec],
to_inline_datum_cbor: Option<&[u8]>,
to_reference_script: Option<ReferenceScriptSpec<'_>>,
params: &ProtocolParams,
) -> Result<Vec<u8>, WalletError> {
let private = payment_key_to_private(payment_key)?;
let (built, _summary) = prepare_payment(
@ -761,6 +833,7 @@ pub fn build_signed_payment_with_assets(
lovelace,
assets_to_send,
to_inline_datum_cbor,
to_reference_script,
params,
)?;
let signed = built
@ -807,6 +880,37 @@ pub fn build_unsigned_payment_with_assets(
assets_to_send: &[AssetSpec],
to_inline_datum_cbor: Option<&[u8]>,
params: &ProtocolParams,
) -> Result<UnsignedPayment, WalletError> {
build_unsigned_payment_extras(
network,
available_utxos,
change_address_bech32,
to_address_bech32,
lovelace,
assets_to_send,
to_inline_datum_cbor,
None,
params,
)
}
/// Build a Conway-era payment with the full output extras (ADA +
/// native assets + optional inline datum + optional reference script)
/// without signing. Returns unsigned CBOR + `PaymentSummary`. Caller
/// signs + submits via the cold-sign path.
///
/// See [`build_signed_payment_extras`] for the signed variant.
#[allow(clippy::too_many_arguments)]
pub fn build_unsigned_payment_extras(
network: Network,
available_utxos: &[InputUtxo],
change_address_bech32: &str,
to_address_bech32: &str,
lovelace: u64,
assets_to_send: &[AssetSpec],
to_inline_datum_cbor: Option<&[u8]>,
to_reference_script: Option<ReferenceScriptSpec<'_>>,
params: &ProtocolParams,
) -> Result<UnsignedPayment, WalletError> {
let (built, summary) = prepare_payment(
network,
@ -816,6 +920,7 @@ pub fn build_unsigned_payment_with_assets(
lovelace,
assets_to_send,
to_inline_datum_cbor,
to_reference_script,
params,
)?;
Ok(UnsignedPayment {