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
2ff007ccaf
commit
0777e9e91d
2 changed files with 107 additions and 1 deletions
|
|
@ -348,6 +348,7 @@ fn output_with_assets(
|
||||||
addr: &PallasAddress,
|
addr: &PallasAddress,
|
||||||
lovelace: u64,
|
lovelace: u64,
|
||||||
assets: &std::collections::BTreeMap<String, u64>,
|
assets: &std::collections::BTreeMap<String, u64>,
|
||||||
|
inline_datum_cbor: Option<&[u8]>,
|
||||||
) -> Result<Output, WalletError> {
|
) -> Result<Output, WalletError> {
|
||||||
let mut out = Output::new(addr.clone(), lovelace);
|
let mut out = Output::new(addr.clone(), lovelace);
|
||||||
for (key, qty) in assets {
|
for (key, qty) in assets {
|
||||||
|
|
@ -361,6 +362,14 @@ fn output_with_assets(
|
||||||
.add_asset(policy, name, *qty)
|
.add_asset(policy, name, *qty)
|
||||||
.map_err(|e| WalletError::Derivation(format!("output add_asset: {e}")))?;
|
.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)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -370,6 +379,7 @@ fn build_staging_with_fee(
|
||||||
to_addr: &PallasAddress,
|
to_addr: &PallasAddress,
|
||||||
to_lovelace: u64,
|
to_lovelace: u64,
|
||||||
to_assets: &std::collections::BTreeMap<String, u64>,
|
to_assets: &std::collections::BTreeMap<String, u64>,
|
||||||
|
to_inline_datum_cbor: Option<&[u8]>,
|
||||||
change_addr: &PallasAddress,
|
change_addr: &PallasAddress,
|
||||||
change_lovelace: u64,
|
change_lovelace: u64,
|
||||||
change_assets: &std::collections::BTreeMap<String, 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)?;
|
let h = parse_tx_hash(&u.tx_hash_hex)?;
|
||||||
staging = staging.input(Input::new(h, u.output_index as u64));
|
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
|
let nonzero_change_assets: std::collections::BTreeMap<String, u64> = change_assets
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, q)| **q > 0)
|
.filter(|(_, q)| **q > 0)
|
||||||
.map(|(k, v)| (k.clone(), *v))
|
.map(|(k, v)| (k.clone(), *v))
|
||||||
.collect();
|
.collect();
|
||||||
if change_lovelace > 0 || !nonzero_change_assets.is_empty() {
|
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(
|
staging = staging.output(output_with_assets(
|
||||||
change_addr,
|
change_addr,
|
||||||
change_lovelace,
|
change_lovelace,
|
||||||
&nonzero_change_assets,
|
&nonzero_change_assets,
|
||||||
|
None,
|
||||||
)?);
|
)?);
|
||||||
}
|
}
|
||||||
staging = staging.fee(fee).network_id(network_id);
|
staging = staging.fee(fee).network_id(network_id);
|
||||||
|
|
@ -452,6 +470,13 @@ fn build_unsigned_bytes(
|
||||||
/// the final `BuiltTransaction` plus a `PaymentSummary` describing
|
/// the final `BuiltTransaction` plus a `PaymentSummary` describing
|
||||||
/// the body. Handles both ADA-only and multi-asset payments; pass
|
/// the body. Handles both ADA-only and multi-asset payments; pass
|
||||||
/// `&[]` for `assets_to_send` to keep it ADA-only.
|
/// `&[]` 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(
|
fn prepare_payment(
|
||||||
network: Network,
|
network: Network,
|
||||||
available_utxos: &[InputUtxo],
|
available_utxos: &[InputUtxo],
|
||||||
|
|
@ -459,6 +484,7 @@ fn prepare_payment(
|
||||||
to_address_bech32: &str,
|
to_address_bech32: &str,
|
||||||
lovelace: u64,
|
lovelace: u64,
|
||||||
assets_to_send: &[AssetSpec],
|
assets_to_send: &[AssetSpec],
|
||||||
|
to_inline_datum_cbor: Option<&[u8]>,
|
||||||
params: &ProtocolParams,
|
params: &ProtocolParams,
|
||||||
) -> Result<(BuiltTransaction, PaymentSummary), WalletError> {
|
) -> Result<(BuiltTransaction, PaymentSummary), WalletError> {
|
||||||
let to_addr = parse_address(to_address_bech32)?;
|
let to_addr = parse_address(to_address_bech32)?;
|
||||||
|
|
@ -546,6 +572,7 @@ fn prepare_payment(
|
||||||
&to_addr,
|
&to_addr,
|
||||||
lovelace,
|
lovelace,
|
||||||
&target_assets,
|
&target_assets,
|
||||||
|
to_inline_datum_cbor,
|
||||||
&change_addr,
|
&change_addr,
|
||||||
change_pass1,
|
change_pass1,
|
||||||
&change_assets,
|
&change_assets,
|
||||||
|
|
@ -596,6 +623,7 @@ fn prepare_payment(
|
||||||
&to_addr,
|
&to_addr,
|
||||||
lovelace,
|
lovelace,
|
||||||
&target_assets,
|
&target_assets,
|
||||||
|
to_inline_datum_cbor,
|
||||||
&change_addr,
|
&change_addr,
|
||||||
final_change,
|
final_change,
|
||||||
&change_assets,
|
&change_assets,
|
||||||
|
|
@ -694,12 +722,18 @@ pub fn build_signed_payment(
|
||||||
to_address_bech32,
|
to_address_bech32,
|
||||||
lovelace,
|
lovelace,
|
||||||
&[],
|
&[],
|
||||||
|
None,
|
||||||
params,
|
params,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build + sign a Conway-era payment that may include native assets.
|
/// Build + sign a Conway-era payment that may include native assets.
|
||||||
/// Returns the signed CBOR bytes ready for `ChainBackend::submit_tx`.
|
/// 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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn build_signed_payment_with_assets(
|
pub fn build_signed_payment_with_assets(
|
||||||
payment_key: &PaymentKey,
|
payment_key: &PaymentKey,
|
||||||
|
|
@ -709,6 +743,7 @@ pub fn build_signed_payment_with_assets(
|
||||||
to_address_bech32: &str,
|
to_address_bech32: &str,
|
||||||
lovelace: u64,
|
lovelace: u64,
|
||||||
assets_to_send: &[AssetSpec],
|
assets_to_send: &[AssetSpec],
|
||||||
|
to_inline_datum_cbor: Option<&[u8]>,
|
||||||
params: &ProtocolParams,
|
params: &ProtocolParams,
|
||||||
) -> Result<Vec<u8>, WalletError> {
|
) -> Result<Vec<u8>, WalletError> {
|
||||||
let private = payment_key_to_private(payment_key)?;
|
let private = payment_key_to_private(payment_key)?;
|
||||||
|
|
@ -719,6 +754,7 @@ pub fn build_signed_payment_with_assets(
|
||||||
to_address_bech32,
|
to_address_bech32,
|
||||||
lovelace,
|
lovelace,
|
||||||
assets_to_send,
|
assets_to_send,
|
||||||
|
to_inline_datum_cbor,
|
||||||
params,
|
params,
|
||||||
)?;
|
)?;
|
||||||
let signed = built
|
let signed = built
|
||||||
|
|
@ -744,6 +780,7 @@ pub fn build_unsigned_payment(
|
||||||
to_address_bech32,
|
to_address_bech32,
|
||||||
lovelace,
|
lovelace,
|
||||||
&[],
|
&[],
|
||||||
|
None,
|
||||||
params,
|
params,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -752,6 +789,8 @@ pub fn build_unsigned_payment(
|
||||||
/// signing. Returns the unsigned CBOR + a `PaymentSummary` for human
|
/// signing. Returns the unsigned CBOR + a `PaymentSummary` for human
|
||||||
/// review. Caller signs externally and submits via
|
/// review. Caller signs externally and submits via
|
||||||
/// `ChainBackend::submit_tx`.
|
/// `ChainBackend::submit_tx`.
|
||||||
|
///
|
||||||
|
/// `to_inline_datum_cbor` — see [`build_signed_payment_with_assets`].
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn build_unsigned_payment_with_assets(
|
pub fn build_unsigned_payment_with_assets(
|
||||||
network: Network,
|
network: Network,
|
||||||
|
|
@ -760,6 +799,7 @@ pub fn build_unsigned_payment_with_assets(
|
||||||
to_address_bech32: &str,
|
to_address_bech32: &str,
|
||||||
lovelace: u64,
|
lovelace: u64,
|
||||||
assets_to_send: &[AssetSpec],
|
assets_to_send: &[AssetSpec],
|
||||||
|
to_inline_datum_cbor: Option<&[u8]>,
|
||||||
params: &ProtocolParams,
|
params: &ProtocolParams,
|
||||||
) -> Result<UnsignedPayment, WalletError> {
|
) -> Result<UnsignedPayment, WalletError> {
|
||||||
let (built, summary) = prepare_payment(
|
let (built, summary) = prepare_payment(
|
||||||
|
|
@ -769,6 +809,7 @@ pub fn build_unsigned_payment_with_assets(
|
||||||
to_address_bech32,
|
to_address_bech32,
|
||||||
lovelace,
|
lovelace,
|
||||||
assets_to_send,
|
assets_to_send,
|
||||||
|
to_inline_datum_cbor,
|
||||||
params,
|
params,
|
||||||
)?;
|
)?;
|
||||||
Ok(UnsignedPayment {
|
Ok(UnsignedPayment {
|
||||||
|
|
@ -1090,6 +1131,7 @@ mod tests {
|
||||||
asset_name_hex: asset_name.to_string(),
|
asset_name_hex: asset_name.to_string(),
|
||||||
quantity: 100,
|
quantity: 100,
|
||||||
}],
|
}],
|
||||||
|
None,
|
||||||
&ProtocolParams::default(),
|
&ProtocolParams::default(),
|
||||||
)
|
)
|
||||||
.expect("multi-asset payment builds + signs");
|
.expect("multi-asset payment builds + signs");
|
||||||
|
|
@ -1118,6 +1160,7 @@ mod tests {
|
||||||
asset_name_hex: asset_name.to_string(),
|
asset_name_hex: asset_name.to_string(),
|
||||||
quantity: 100,
|
quantity: 100,
|
||||||
}],
|
}],
|
||||||
|
None,
|
||||||
&ProtocolParams::default(),
|
&ProtocolParams::default(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -1129,6 +1172,46 @@ mod tests {
|
||||||
assert_eq!(result.summary.change_assets[0].policy_id_hex, policy);
|
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
|
/// AUDIT5-1 regression: ada-only sends should be allowed to drain
|
||||||
/// a wallet down to "all of input - fee" without the selector
|
/// a wallet down to "all of input - fee" without the selector
|
||||||
/// reserving min_utxo for a change output that ends up folded
|
/// 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.
|
/// (hex of raw bytes, 0-64 chars) + quantity.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub assets: Vec<McpAssetSpec>,
|
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
|
/// Bypass the configured `max_send_lovelace` hard cap. Only
|
||||||
/// pass `true` for an intentional, user-confirmed large send.
|
/// pass `true` for an intentional, user-confirmed large send.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|
@ -144,6 +152,9 @@ pub struct UnsignedSendArgs {
|
||||||
/// Optional native assets to include in the payment output.
|
/// Optional native assets to include in the payment output.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub assets: Vec<McpAssetSpec>,
|
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)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
|
|
@ -411,6 +422,7 @@ impl WalletService {
|
||||||
to_address,
|
to_address,
|
||||||
lovelace,
|
lovelace,
|
||||||
assets,
|
assets,
|
||||||
|
datum_inline_cbor_hex,
|
||||||
force,
|
force,
|
||||||
}: SendArgs,
|
}: SendArgs,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
|
|
@ -456,6 +468,10 @@ impl WalletService {
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let asset_specs: Vec<AssetSpec> = assets.into_iter().map(Into::into).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(
|
let cbor = build_signed_payment_with_assets(
|
||||||
&self.inner.payment_key,
|
&self.inner.payment_key,
|
||||||
|
|
@ -465,6 +481,7 @@ impl WalletService {
|
||||||
&to_address,
|
&to_address,
|
||||||
lovelace,
|
lovelace,
|
||||||
&asset_specs,
|
&asset_specs,
|
||||||
|
datum_bytes.as_deref(),
|
||||||
&ProtocolParams::default(),
|
&ProtocolParams::default(),
|
||||||
)
|
)
|
||||||
.map_err(|e| format!("build/sign: {e}"))?;
|
.map_err(|e| format!("build/sign: {e}"))?;
|
||||||
|
|
@ -505,6 +522,7 @@ impl WalletService {
|
||||||
to_address,
|
to_address,
|
||||||
lovelace,
|
lovelace,
|
||||||
assets,
|
assets,
|
||||||
|
datum_inline_cbor_hex,
|
||||||
}: UnsignedSendArgs,
|
}: UnsignedSendArgs,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
if lovelace == 0 {
|
if lovelace == 0 {
|
||||||
|
|
@ -533,6 +551,10 @@ impl WalletService {
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let asset_specs: Vec<AssetSpec> = assets.into_iter().map(Into::into).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(
|
let unsigned = build_unsigned_payment_with_assets(
|
||||||
self.inner.network,
|
self.inner.network,
|
||||||
|
|
@ -541,6 +563,7 @@ impl WalletService {
|
||||||
&to_address,
|
&to_address,
|
||||||
lovelace,
|
lovelace,
|
||||||
&asset_specs,
|
&asset_specs,
|
||||||
|
datum_bytes.as_deref(),
|
||||||
&ProtocolParams::default(),
|
&ProtocolParams::default(),
|
||||||
)
|
)
|
||||||
.map_err(|e| format!("build: {e}"))?;
|
.map_err(|e| format!("build: {e}"))?;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue