AUDIT4-3 fix: optional inline datum on wallet_send

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.
This commit is contained in:
Kayos 2026-05-05 06:58:15 -07:00
parent e712f370f0
commit 1ee124b545
2 changed files with 107 additions and 1 deletions

View file

@ -348,6 +348,7 @@ fn output_with_assets(
addr: &PallasAddress,
lovelace: u64,
assets: &std::collections::BTreeMap<String, u64>,
inline_datum_cbor: Option<&[u8]>,
) -> Result<Output, WalletError> {
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<String, u64>,
to_inline_datum_cbor: Option<&[u8]>,
change_addr: &PallasAddress,
change_lovelace: u64,
change_assets: &std::collections::BTreeMap<String, u64>,
@ -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<String, u64> = 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<Vec<u8>, 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<UnsignedPayment, WalletError> {
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

View file

@ -123,6 +123,14 @@ pub struct SendArgs {
/// (hex of raw bytes, 0-64 chars) + quantity.
#[serde(default)]
pub assets: Vec<McpAssetSpec>,
/// 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<String>,
/// 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<McpAssetSpec>,
/// Optional inline-datum CBOR (hex). See [`SendArgs::datum_inline_cbor_hex`].
#[serde(default)]
pub datum_inline_cbor_hex: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
@ -411,6 +422,7 @@ impl WalletService {
to_address,
lovelace,
assets,
datum_inline_cbor_hex,
force,
}: SendArgs,
) -> Result<String, String> {
@ -456,6 +468,10 @@ impl WalletService {
})
.collect();
let asset_specs: Vec<AssetSpec> = 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<String, String> {
if lovelace == 0 {
@ -533,6 +551,10 @@ impl WalletService {
})
.collect();
let asset_specs: Vec<AssetSpec> = 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}"))?;