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:
parent
e712f370f0
commit
1ee124b545
2 changed files with 107 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"))?;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue