From 1ee124b545842e2f82ab348bbfa06f7e12225f6d Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 5 May 2026 06:58:15 -0700 Subject: [PATCH] AUDIT4-3 fix: optional inline datum on wallet_send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wallet_send + wallet_send_unsigned now accept an optional datum_inline_cbor_hex field. When set, the recipient output carries the bytes as an inline datum — the right shape for locking funds at a script address with a datum the validator can read. Without this, sends to script addresses created un-spendable utxos (Babbage/Conway rejects spending script utxos that don't carry a datum). Surfaced 2026-05-04 audit-4 phase F2 when the always-succeeds Aiken validator's locked utxo couldn't be spent back due to NotAllowedSupplementalDatums + PPViewHashesDontMatch chain errors. Plumbed through: build_signed_payment_with_assets (added arg) build_unsigned_payment_with_assets (added arg) prepare_payment (added arg) build_staging_with_fee (added arg) output_with_assets (added arg) SendArgs / UnsignedSendArgs (new optional MCP field) Change outputs never get a datum — they go back to the wallet which has no validator to satisfy, so the field is wired only to the recipient output. Test lock_with_inline_datum_attaches_datum_to_output decodes the resulting tx CBOR and confirms the recipient output's datum_option is populated. Unblocks mainnet Plutus testing — the spend round trip can now build a lock that the spend side can satisfy. --- crates/aldabra-core/src/tx.rs | 85 ++++++++++++++++++++++++++++++++- crates/aldabra-mcp/src/tools.rs | 23 +++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/crates/aldabra-core/src/tx.rs b/crates/aldabra-core/src/tx.rs index 2f9dec8..3957204 100644 --- a/crates/aldabra-core/src/tx.rs +++ b/crates/aldabra-core/src/tx.rs @@ -348,6 +348,7 @@ fn output_with_assets( addr: &PallasAddress, lovelace: u64, assets: &std::collections::BTreeMap, + inline_datum_cbor: Option<&[u8]>, ) -> Result { let mut out = Output::new(addr.clone(), lovelace); for (key, qty) in assets { @@ -361,6 +362,14 @@ fn output_with_assets( .add_asset(policy, name, *qty) .map_err(|e| WalletError::Derivation(format!("output add_asset: {e}")))?; } + // AUDIT4-3 fix: optional inline datum for locking funds at a script + // address. Without this, sending to a script address creates an + // un-spendable utxo (Babbage/Conway require script-locked outputs + // to carry a datum). Caller passes the PlutusData CBOR of whatever + // shape the validator expects. + if let Some(datum) = inline_datum_cbor { + out = out.set_inline_datum(datum.to_vec()); + } Ok(out) } @@ -370,6 +379,7 @@ fn build_staging_with_fee( to_addr: &PallasAddress, to_lovelace: u64, to_assets: &std::collections::BTreeMap, + to_inline_datum_cbor: Option<&[u8]>, change_addr: &PallasAddress, change_lovelace: u64, change_assets: &std::collections::BTreeMap, @@ -381,17 +391,25 @@ fn build_staging_with_fee( let h = parse_tx_hash(&u.tx_hash_hex)?; staging = staging.input(Input::new(h, u.output_index as u64)); } - staging = staging.output(output_with_assets(to_addr, to_lovelace, to_assets)?); + staging = staging.output(output_with_assets( + to_addr, + to_lovelace, + to_assets, + to_inline_datum_cbor, + )?); let nonzero_change_assets: std::collections::BTreeMap = change_assets .iter() .filter(|(_, q)| **q > 0) .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. staging = staging.output(output_with_assets( change_addr, change_lovelace, &nonzero_change_assets, + None, )?); } staging = staging.fee(fee).network_id(network_id); @@ -452,6 +470,13 @@ fn build_unsigned_bytes( /// the final `BuiltTransaction` plus a `PaymentSummary` describing /// the body. Handles both ADA-only and multi-asset payments; pass /// `&[]` for `assets_to_send` to keep it ADA-only. +/// +/// `to_inline_datum_cbor`, when `Some`, attaches the bytes as an +/// inline datum on the recipient output. Used to lock funds at a +/// 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. +#[allow(clippy::too_many_arguments)] fn prepare_payment( network: Network, available_utxos: &[InputUtxo], @@ -459,6 +484,7 @@ fn prepare_payment( to_address_bech32: &str, lovelace: u64, assets_to_send: &[AssetSpec], + to_inline_datum_cbor: Option<&[u8]>, params: &ProtocolParams, ) -> Result<(BuiltTransaction, PaymentSummary), WalletError> { let to_addr = parse_address(to_address_bech32)?; @@ -546,6 +572,7 @@ fn prepare_payment( &to_addr, lovelace, &target_assets, + to_inline_datum_cbor, &change_addr, change_pass1, &change_assets, @@ -596,6 +623,7 @@ fn prepare_payment( &to_addr, lovelace, &target_assets, + to_inline_datum_cbor, &change_addr, final_change, &change_assets, @@ -694,12 +722,18 @@ pub fn build_signed_payment( to_address_bech32, lovelace, &[], + None, params, ) } /// Build + sign a Conway-era payment that may include native assets. /// Returns the signed CBOR bytes ready for `ChainBackend::submit_tx`. +/// +/// `to_inline_datum_cbor`, when `Some`, attaches the bytes as an +/// inline datum on the recipient output — needed to lock funds at +/// a script address with a datum the validator can read (AUDIT4-3 +/// fix). `None` for normal sends to wallet addresses. #[allow(clippy::too_many_arguments)] pub fn build_signed_payment_with_assets( payment_key: &PaymentKey, @@ -709,6 +743,7 @@ pub fn build_signed_payment_with_assets( to_address_bech32: &str, lovelace: u64, assets_to_send: &[AssetSpec], + to_inline_datum_cbor: Option<&[u8]>, params: &ProtocolParams, ) -> Result, WalletError> { let private = payment_key_to_private(payment_key)?; @@ -719,6 +754,7 @@ pub fn build_signed_payment_with_assets( to_address_bech32, lovelace, assets_to_send, + to_inline_datum_cbor, params, )?; let signed = built @@ -744,6 +780,7 @@ pub fn build_unsigned_payment( to_address_bech32, lovelace, &[], + None, params, ) } @@ -752,6 +789,8 @@ pub fn build_unsigned_payment( /// signing. Returns the unsigned CBOR + a `PaymentSummary` for human /// review. Caller signs externally and submits via /// `ChainBackend::submit_tx`. +/// +/// `to_inline_datum_cbor` — see [`build_signed_payment_with_assets`]. #[allow(clippy::too_many_arguments)] pub fn build_unsigned_payment_with_assets( network: Network, @@ -760,6 +799,7 @@ pub fn build_unsigned_payment_with_assets( to_address_bech32: &str, lovelace: u64, assets_to_send: &[AssetSpec], + to_inline_datum_cbor: Option<&[u8]>, params: &ProtocolParams, ) -> Result { let (built, summary) = prepare_payment( @@ -769,6 +809,7 @@ pub fn build_unsigned_payment_with_assets( to_address_bech32, lovelace, assets_to_send, + to_inline_datum_cbor, params, )?; Ok(UnsignedPayment { @@ -1090,6 +1131,7 @@ mod tests { asset_name_hex: asset_name.to_string(), quantity: 100, }], + None, &ProtocolParams::default(), ) .expect("multi-asset payment builds + signs"); @@ -1118,6 +1160,7 @@ mod tests { asset_name_hex: asset_name.to_string(), quantity: 100, }], + None, &ProtocolParams::default(), ) .unwrap(); @@ -1129,6 +1172,46 @@ mod tests { assert_eq!(result.summary.change_assets[0].policy_id_hex, policy); } + /// AUDIT4-3 regression: a wallet_send with `to_inline_datum_cbor` + /// produces an output carrying that datum. Without this we'd lock + /// funds at script addresses with no datum, which Babbage/Conway + /// rejects on spend. Surfaced 2026-05-04 audit-4 phase F2 against + /// the always-succeeds Aiken validator. + #[test] + fn lock_with_inline_datum_attaches_datum_to_output() { + use pallas_primitives::Fragment; + let payment = payment_from_canonical(); + let change = change_address(Network::Preprod); + let utxos = vec![single_ada_utxo(100_000_000)]; + // Plutus Constr 0 [] = CBOR `d87980` — same shape we use as a + // unit redeemer for always-succeeds. + let datum = vec![0xd8, 0x79, 0x80]; + let cbor = build_signed_payment_with_assets( + &payment, + Network::Preprod, + &utxos, + &change, + &to_address_preprod(), + 10_000_000, + &[], + Some(&datum), + &ProtocolParams::default(), + ) + .expect("lock with inline datum builds + signs"); + let tx = pallas_primitives::conway::Tx::decode_fragment(&cbor) + .expect("decode lock-with-datum tx cbor"); + // First output is the recipient — must carry an inline datum. + let outs = tx.transaction_body.outputs.to_vec(); + assert!(!outs.is_empty(), "expected ≥1 output"); + match &outs[0] { + pallas_primitives::conway::PseudoTransactionOutput::PostAlonzo(o) => { + let datum_present = o.datum_option.is_some(); + assert!(datum_present, "first output must carry inline datum"); + } + _ => panic!("expected PostAlonzo output shape"), + } + } + /// AUDIT5-1 regression: ada-only sends should be allowed to drain /// a wallet down to "all of input - fee" without the selector /// reserving min_utxo for a change output that ends up folded diff --git a/crates/aldabra-mcp/src/tools.rs b/crates/aldabra-mcp/src/tools.rs index 7928d07..6d28eaf 100644 --- a/crates/aldabra-mcp/src/tools.rs +++ b/crates/aldabra-mcp/src/tools.rs @@ -123,6 +123,14 @@ pub struct SendArgs { /// (hex of raw bytes, 0-64 chars) + quantity. #[serde(default)] pub assets: Vec, + /// Optional inline-datum CBOR (hex). When set, the recipient + /// output carries the bytes as an inline datum — the right + /// shape for locking funds at a script address with a datum + /// the validator can read. Required for any spend-back path + /// against Babbage/Conway-era validators (script utxos without + /// a datum are un-spendable). Omit for normal sends. + #[serde(default)] + pub datum_inline_cbor_hex: Option, /// Bypass the configured `max_send_lovelace` hard cap. Only /// pass `true` for an intentional, user-confirmed large send. #[serde(default)] @@ -144,6 +152,9 @@ pub struct UnsignedSendArgs { /// Optional native assets to include in the payment output. #[serde(default)] pub assets: Vec, + /// Optional inline-datum CBOR (hex). See [`SendArgs::datum_inline_cbor_hex`]. + #[serde(default)] + pub datum_inline_cbor_hex: Option, } #[derive(Debug, Deserialize, schemars::JsonSchema)] @@ -411,6 +422,7 @@ impl WalletService { to_address, lovelace, assets, + datum_inline_cbor_hex, force, }: SendArgs, ) -> Result { @@ -456,6 +468,10 @@ impl WalletService { }) .collect(); let asset_specs: Vec = assets.into_iter().map(Into::into).collect(); + let datum_bytes = match datum_inline_cbor_hex.as_deref() { + Some(s) => Some(hex_decode(s).map_err(|e| format!("decode datum: {e}"))?), + None => None, + }; let cbor = build_signed_payment_with_assets( &self.inner.payment_key, @@ -465,6 +481,7 @@ impl WalletService { &to_address, lovelace, &asset_specs, + datum_bytes.as_deref(), &ProtocolParams::default(), ) .map_err(|e| format!("build/sign: {e}"))?; @@ -505,6 +522,7 @@ impl WalletService { to_address, lovelace, assets, + datum_inline_cbor_hex, }: UnsignedSendArgs, ) -> Result { if lovelace == 0 { @@ -533,6 +551,10 @@ impl WalletService { }) .collect(); let asset_specs: Vec = assets.into_iter().map(Into::into).collect(); + let datum_bytes = match datum_inline_cbor_hex.as_deref() { + Some(s) => Some(hex_decode(s).map_err(|e| format!("decode datum: {e}"))?), + None => None, + }; let unsigned = build_unsigned_payment_with_assets( self.inner.network, @@ -541,6 +563,7 @@ impl WalletService { &to_address, lovelace, &asset_specs, + datum_bytes.as_deref(), &ProtocolParams::default(), ) .map_err(|e| format!("build: {e}"))?;