From b9124ee5d96dce06f04fe993b4db3d3b15741c22 Mon Sep 17 00:00:00 2001 From: Kayos Date: Thu, 7 May 2026 06:17:07 -0700 Subject: [PATCH] feat(wallet): reference-script + extras on payment outputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` and `reference_script_kind: Option` - 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 Kayos'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. --- crates/aldabra-core/src/lib.rs | 8 ++- crates/aldabra-core/src/tx.rs | 111 +++++++++++++++++++++++++++++++- crates/aldabra-mcp/src/tools.rs | 94 +++++++++++++++++++++++++-- 3 files changed, 201 insertions(+), 12 deletions(-) diff --git a/crates/aldabra-core/src/lib.rs b/crates/aldabra-core/src/lib.rs index 866504a..2069963 100644 --- a/crates/aldabra-core/src/lib.rs +++ b/crates/aldabra-core/src/lib.rs @@ -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 { diff --git a/crates/aldabra-core/src/tx.rs b/crates/aldabra-core/src/tx.rs index a078ef8..e06a3c1 100644 --- a/crates/aldabra-core/src/tx.rs +++ b/crates/aldabra-core/src/tx.rs @@ -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, inline_datum_cbor: Option<&[u8]>, + reference_script: Option>, ) -> Result { 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, to_inline_datum_cbor: Option<&[u8]>, + to_reference_script: Option>, change_addr: &PallasAddress, change_lovelace: u64, change_assets: &std::collections::BTreeMap, @@ -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 = 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>, 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, 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>, + params: &ProtocolParams, ) -> Result, 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 { + 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>, + params: &ProtocolParams, ) -> Result { 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 { diff --git a/crates/aldabra-mcp/src/tools.rs b/crates/aldabra-mcp/src/tools.rs index e5a470f..d8cc914 100644 --- a/crates/aldabra-mcp/src/tools.rs +++ b/crates/aldabra-mcp/src/tools.rs @@ -52,11 +52,29 @@ use aldabra_dao::reader::{DaoReader, KoiosDaoReader}; use aldabra_core::plutus_cost_models::PLUTUS_V3_COST_MODEL_PREPROD; use aldabra_core::{ add_witness, build_signed_cip68_nft_mint, build_signed_mint_with_metadata, - build_signed_payment_with_assets, build_signed_plutus_spend, build_signed_stake_delegation, - build_unsigned_mint, build_unsigned_payment_with_assets, hex_decode, summarize_tx, AssetSpec, - InputUtxo, Network, PaymentKey, PlutusExUnits, PlutusInput, PlutusVersion, PolicySpec, - ProtocolParams, StakeKey, DEFAULT_EX_UNITS, + build_signed_payment_extras, build_signed_payment_with_assets, build_signed_plutus_spend, + build_signed_stake_delegation, build_unsigned_mint, build_unsigned_payment_extras, + build_unsigned_payment_with_assets, hex_decode, summarize_tx, AssetSpec, InputUtxo, Network, + PaymentKey, PlutusExUnits, PlutusInput, PlutusVersion, PolicySpec, ProtocolParams, + ReferenceScriptSpec, ScriptKind, StakeKey, DEFAULT_EX_UNITS, }; + +/// Parse a user-supplied script-kind string ("PlutusV1" / "PlutusV2" +/// / "PlutusV3" / "Native") into the pallas `ScriptKind` enum used +/// by the reference-script attachment helper. Case-insensitive, +/// trims whitespace; returns a clean error message on miss. +fn parse_script_kind(s: &str) -> Result { + match s.trim().to_ascii_lowercase().as_str() { + "plutusv1" | "v1" => Ok(ScriptKind::PlutusV1), + "plutusv2" | "v2" => Ok(ScriptKind::PlutusV2), + "plutusv3" | "v3" => Ok(ScriptKind::PlutusV3), + "native" => Ok(ScriptKind::Native), + other => Err(format!( + "invalid reference_script_kind '{other}'; expected one of: \ + PlutusV1, PlutusV2, PlutusV3, Native" + )), + } +} use rmcp::{ model::{ServerCapabilities, ServerInfo}, schemars, tool, ServerHandler, @@ -186,6 +204,22 @@ pub struct SendArgs { /// a datum are un-spendable). Omit for normal sends. #[serde(default)] pub datum_inline_cbor_hex: Option, + /// Optional reference-script CBOR (hex). When set, the recipient + /// output carries the script as a reference-script (Babbage/Conway + /// era `--tx-out-reference-script-file` equivalent). Used to + /// deploy a Plutus validator/policy as a reusable on-chain + /// reference so downstream txs can witness it via `--tx-in-script- + /// file ref` instead of inline-witnessing the full CBOR. Pair with + /// `to_address` = wallet's own address so the wallet retains the + /// ability to retire the deployment later. + /// Requires `reference_script_kind` to also be set. + #[serde(default)] + pub reference_script_cbor_hex: Option, + /// Plutus version of the reference-script: "PlutusV1", "PlutusV2", + /// "PlutusV3", or "Native". Required when reference_script_cbor_hex + /// is set; ignored otherwise. + #[serde(default)] + pub reference_script_kind: Option, /// Bypass the configured `max_send_lovelace` hard cap. Only /// pass `true` for an intentional, user-confirmed large send. #[serde(default)] @@ -260,6 +294,14 @@ pub struct UnsignedSendArgs { /// Optional inline-datum CBOR (hex). See [`SendArgs::datum_inline_cbor_hex`]. #[serde(default)] pub datum_inline_cbor_hex: Option, + /// Optional reference-script CBOR (hex). See + /// [`SendArgs::reference_script_cbor_hex`]. + #[serde(default)] + pub reference_script_cbor_hex: Option, + /// "PlutusV1" | "PlutusV2" | "PlutusV3" | "Native". See + /// [`SendArgs::reference_script_kind`]. + #[serde(default)] + pub reference_script_kind: Option, } #[derive(Debug, Deserialize, schemars::JsonSchema)] @@ -574,6 +616,8 @@ impl WalletService { lovelace, assets, datum_inline_cbor_hex, + reference_script_cbor_hex, + reference_script_kind, force, }: SendArgs, ) -> Result { @@ -623,8 +667,25 @@ impl WalletService { Some(s) => Some(hex_decode(s).map_err(|e| format!("decode datum: {e}"))?), None => None, }; + let ref_script_bytes = match reference_script_cbor_hex.as_deref() { + Some(s) => Some(hex_decode(s).map_err(|e| format!("decode reference_script: {e}"))?), + None => None, + }; + let ref_script = match (ref_script_bytes.as_ref(), reference_script_kind.as_deref()) { + (Some(bytes), Some(kind)) => Some(ReferenceScriptSpec { + kind: parse_script_kind(kind)?, + cbor: bytes.as_slice(), + }), + (Some(_), None) => { + return Err("reference_script_cbor_hex set without reference_script_kind".into()) + } + (None, Some(_)) => { + return Err("reference_script_kind set without reference_script_cbor_hex".into()) + } + (None, None) => None, + }; - let cbor = build_signed_payment_with_assets( + let cbor = build_signed_payment_extras( &self.inner.payment_key, self.inner.network, &inputs, @@ -633,6 +694,7 @@ impl WalletService { lovelace, &asset_specs, datum_bytes.as_deref(), + ref_script, &ProtocolParams::default(), ) .map_err(|e| format!("build/sign: {e}"))?; @@ -674,6 +736,8 @@ impl WalletService { lovelace, assets, datum_inline_cbor_hex, + reference_script_cbor_hex, + reference_script_kind, }: UnsignedSendArgs, ) -> Result { if lovelace == 0 { @@ -706,8 +770,25 @@ impl WalletService { Some(s) => Some(hex_decode(s).map_err(|e| format!("decode datum: {e}"))?), None => None, }; + let ref_script_bytes = match reference_script_cbor_hex.as_deref() { + Some(s) => Some(hex_decode(s).map_err(|e| format!("decode reference_script: {e}"))?), + None => None, + }; + let ref_script = match (ref_script_bytes.as_ref(), reference_script_kind.as_deref()) { + (Some(bytes), Some(kind)) => Some(ReferenceScriptSpec { + kind: parse_script_kind(kind)?, + cbor: bytes.as_slice(), + }), + (Some(_), None) => { + return Err("reference_script_cbor_hex set without reference_script_kind".into()) + } + (None, Some(_)) => { + return Err("reference_script_kind set without reference_script_cbor_hex".into()) + } + (None, None) => None, + }; - let unsigned = build_unsigned_payment_with_assets( + let unsigned = build_unsigned_payment_extras( self.inner.network, &inputs, &self.inner.address, @@ -715,6 +796,7 @@ impl WalletService { lovelace, &asset_specs, datum_bytes.as_deref(), + ref_script, &ProtocolParams::default(), ) .map_err(|e| format!("build: {e}"))?;