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:
Sulkta 2026-05-05 06:58:15 -07:00
parent 2ff007ccaf
commit 0777e9e91d
2 changed files with 107 additions and 1 deletions

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}"))?;