chore: strip audit-ticket prefixes from code comments

Drops the ~60 ticket-prefix comments (CRIT-N, HIGH-N, MED-N, LOW-N,
L-N, M-N, AUDIT-N, PLUTUS-N, "audit fix (date):", "Phase N" labels,
"Adversarial-review fix:") that had accumulated in inline + doc
comments over several audit cycles. Where the surrounding prose
still carried useful WHY context it gets kept and tightened; where
the ticket WAS the comment it gets dropped entirely.

No logic, no renames, no behavior change. Audit history lives in
commit messages and the audits/ tree where it belongs — eternal
comments don't need to mirror it.

Net 138 LOC shorter. 253 tests pass, no new clippy or fmt warnings.
This commit is contained in:
Kayos 2026-05-12 14:42:13 -07:00
parent d1c9e7a732
commit 326a559f76
12 changed files with 201 additions and 339 deletions

View file

@ -527,10 +527,9 @@ mod tests {
); );
} }
// (the deserializes_tx_info_response / deserializes_empty_tx_info_response // tx_info-shape tests were dropped when tx_status moved from
// tests were dropped along with KoiosTxInfo when AUDIT4-1 swapped tx_status // /tx_info to /tx_status. Coverage is in parses_koios_tx_status_shapes
// from /tx_info to /tx_status. Coverage is now in // below.
// parses_koios_tx_status_shapes below.)
#[test] #[test]
fn tx_status_serializes_with_tag() { fn tx_status_serializes_with_tag() {

View file

@ -1,10 +1,10 @@
//! Decode + summarize a Conway-era tx CBOR for human review. //! Decode + summarize a Conway-era tx CBOR for human review.
//! //!
//! Used by `wallet.tx_summary` (Phase 4 / audit HIGH-2 fix). Before //! Backs `wallet.tx_summary`. Before handing a pre-built CBOR to
//! a caller hands a pre-built CBOR to `wallet.sign_partial` or //! `wallet.sign_partial` or `wallet.submit_signed_tx`, callers should
//! `wallet.submit_signed_tx`, they should pull a summary through here //! summarize through here and read what the tx actually does —
//! and review what the tx actually does — what's being spent, where //! what's being spent, where funds are going, what's being minted,
//! funds are going, what's being minted, what certs are present. //! what certs are present.
//! //!
//! No I/O. Pure decode → typed summary. Caller serializes to JSON. //! No I/O. Pure decode → typed summary. Caller serializes to JSON.

View file

@ -180,7 +180,6 @@ impl Mnemonic {
// `xprv_bytes` was moved into normalize_bytes_force3rd, but // `xprv_bytes` was moved into normalize_bytes_force3rd, but
// the stack slot can still hold a copy depending on calling // the stack slot can still hold a copy depending on calling
// conventions / inlining. Defensive zeroize. // conventions / inlining. Defensive zeroize.
// (M-1 audit fix.)
xprv_bytes.zeroize(); xprv_bytes.zeroize();
Ok(RootKey { xprv }) Ok(RootKey { xprv })
} }

View file

@ -9,7 +9,7 @@
//! asset under that policy and sends them to a destination //! asset under that policy and sends them to a destination
//! address. //! address.
//! //!
//! ## Phase 3 scope today //! Current scope:
//! //!
//! - **Single-sig** + **single-sig with `invalid_after`** policy //! - **Single-sig** + **single-sig with `invalid_after`** policy
//! shapes — the bread-and-butter wallet-controlled mint. //! shapes — the bread-and-butter wallet-controlled mint.

View file

@ -10,11 +10,9 @@
//! - a collateral input (≥ 5 ADA) from the wallet's normal UTXOs; //! - a collateral input (≥ 5 ADA) from the wallet's normal UTXOs;
//! consumed if the script fails on-chain. //! consumed if the script fails on-chain.
//! //!
//! ## Phase 4.14.3 scope //! Current scope: single Plutus input, single output, single collateral.
//! //! Reference inputs and live ExUnits estimation are follow-ups; ExUnits
//! Single Plutus input, single output, single collateral. Reference //! today come from the caller.
//! inputs (4.2 expansion) and live ExUnits estimation (4.4) are
//! follow-ups. ExUnits today come from the caller.
use bech32::FromBase32; use bech32::FromBase32;
use pallas_crypto::hash::Hash; use pallas_crypto::hash::Hash;
@ -137,8 +135,8 @@ pub fn build_signed_plutus_spend(
let payout_addr = parse_address(payout_address_bech32)?; let payout_addr = parse_address(payout_address_bech32)?;
let network_id = network_id_for(network); let network_id = network_id_for(network);
// PLUTUS-2 audit fix: collateral MUST be ADA-only (chain rejects // Collateral must be ADA-only — the chain rejects collateral
// collateral inputs that carry native assets). // inputs that carry native assets.
let mut ada_only: Vec<InputUtxo> = available_utxos let mut ada_only: Vec<InputUtxo> = available_utxos
.iter() .iter()
.filter(|u| u.assets.is_empty()) .filter(|u| u.assets.is_empty())
@ -146,13 +144,12 @@ pub fn build_signed_plutus_spend(
.collect(); .collect();
ada_only.sort_by_key(|u| std::cmp::Reverse(u.lovelace)); ada_only.sort_by_key(|u| std::cmp::Reverse(u.lovelace));
// AUDIT4-2 fix: pick the SMALLEST ADA-only UTXO that still // Pick the SMALLEST ADA-only UTXO that still qualifies for
// qualifies for collateral (≥ 5 ADA), so the LARGEST stays // collateral (≥ 5 ADA), so the LARGEST stays available for
// available for funding the spend. The inverse approach (give // funding the spend. Giving collateral the biggest utxo breaks
// collateral the biggest utxo) breaks the common case where a // the common case where a wallet has one large change utxo + a
// wallet has one large change utxo + a small self-send leftover, // small self-send leftover — funding ends up with the scrap and
// since funding ends up with the scrap and can't cover payout + // can't cover payout + fee + min_utxo.
// fee + min_utxo.
// //
// Collateral is NEVER consumed on the happy path — it's only // Collateral is NEVER consumed on the happy path — it's only
// seized if the script fails — so its size beyond the 5-ADA // seized if the script fails — so its size beyond the 5-ADA
@ -170,9 +167,9 @@ pub fn build_signed_plutus_spend(
})? })?
.clone(); .clone();
// PLUTUS-1 audit fix: collateral is NOT consumed on the happy // Collateral is NOT consumed on the happy path — it's only
// path — it's only seized if the script fails. So we need a // seized if the script fails — so we need a SEPARATE regular
// SEPARATE regular input to fund payout + fee + change. // input to fund payout + fee + change.
// //
// Pick the LARGEST ADA-only UTXO that's not the collateral — // Pick the LARGEST ADA-only UTXO that's not the collateral —
// funding has to cover payout + script-execution fee + change // funding has to cover payout + script-execution fee + change
@ -195,8 +192,8 @@ pub fn build_signed_plutus_spend(
// Collateral is held off-line and only consumed on script failure. // Collateral is held off-line and only consumed on script failure.
let total_in = locked.lovelace.saturating_add(funding.lovelace); let total_in = locked.lovelace.saturating_add(funding.lovelace);
// PLUTUS-3 audit fix: Plutus tx fee = size_fee + ExUnits_fee. // Plutus tx fee = size_fee + ExUnits_fee. ExUnits dominates for
// ExUnits dominates for typical scripts (~1.5 ADA at default budget). // typical scripts (~1.5 ADA at default budget).
let ex_fee = params.ex_units_fee(ex_units.mem, ex_units.steps); let ex_fee = params.ex_units_fee(ex_units.mem, ex_units.steps);
let fee_pass1 = 1_000_000u64.saturating_add(ex_fee); let fee_pass1 = 1_000_000u64.saturating_add(ex_fee);
let need = payout_lovelace let need = payout_lovelace
@ -225,8 +222,8 @@ pub fn build_signed_plutus_spend(
let build_with_fee = let build_with_fee =
|fee: u64, change_lovelace: u64| -> Result<StagingTransaction, WalletError> { |fee: u64, change_lovelace: u64| -> Result<StagingTransaction, WalletError> {
let mut staging = StagingTransaction::new(); let mut staging = StagingTransaction::new();
// PLUTUS-1: locked + funding as regular inputs (both consumed // Locked + funding go in as regular inputs (both consumed
// on happy path); collateral as collateral_input only. // on the happy path); collateral as collateral_input only.
staging = staging.input(locked_input.clone()); staging = staging.input(locked_input.clone());
staging = staging.input(funding_input.clone()); staging = staging.input(funding_input.clone());
staging = staging.collateral_input(collateral_input.clone()); staging = staging.collateral_input(collateral_input.clone());
@ -246,11 +243,11 @@ pub fn build_signed_plutus_spend(
if let Some(d) = witness_datum_cbor { if let Some(d) = witness_datum_cbor {
staging = staging.datum(d.to_vec()); staging = staging.datum(d.to_vec());
} }
// PLUTUS-4 audit fix: pallas-txbuilder only computes // pallas-txbuilder only computes script_data_hash when
// script_data_hash if language_view is set. Without it, the // language_view is set; without it the body hash is None
// body's hash is None and the chain rejects with // and the chain rejects with PPViewHashesDontMatch. The
// PPViewHashesDontMatch. PlutusV3 path requires a V3 cost // PlutusV3 path needs a V3 cost model — caller-supplied
// model — caller-supplied via ProtocolParams. // via ProtocolParams.
if let Some(cost_model) = params.plutus_v3_cost_model.as_deref() { if let Some(cost_model) = params.plutus_v3_cost_model.as_deref() {
if matches!(script_version, PlutusVersion::V3) { if matches!(script_version, PlutusVersion::V3) {
staging = staging =
@ -379,7 +376,7 @@ mod tests {
#[test] #[test]
fn ex_units_fee_matches_known_values() { fn ex_units_fee_matches_known_values() {
// PLUTUS-3 audit fix: fee for default budget should be ~1.5 ADA. // Fee for the default budget should be ~1.5 ADA.
let p = ProtocolParams::default(); let p = ProtocolParams::default();
let fee = p.ex_units_fee(DEFAULT_EX_UNITS.mem, DEFAULT_EX_UNITS.steps); let fee = p.ex_units_fee(DEFAULT_EX_UNITS.mem, DEFAULT_EX_UNITS.steps);
// 14M mem * 0.0577 + 10B steps * 7.21e-5 // 14M mem * 0.0577 + 10B steps * 7.21e-5
@ -403,9 +400,8 @@ mod tests {
lovelace: 50_000_000, lovelace: 50_000_000,
}; };
// Only ONE wallet UTXO — collateral pick consumes it; no // Only ONE wallet UTXO — collateral pick consumes it; no
// separate funding UTXO available. PLUTUS-1 fix should // separate funding UTXO available. Should reject with a clear
// reject with a clear error rather than building a tx that // error rather than build a tx the chain rejects.
// chain rejects.
let utxos = vec![InputUtxo { let utxos = vec![InputUtxo {
tx_hash_hex: "cafebabe".repeat(8), tx_hash_hex: "cafebabe".repeat(8),
output_index: 0, output_index: 0,
@ -492,8 +488,8 @@ mod tests {
output_index: 0, output_index: 0,
lovelace: 50_000_000, lovelace: 50_000_000,
}; };
// PLUTUS-1+2 audit fix: spend path needs TWO ADA-only wallet // The spend path needs TWO ADA-only wallet UTXOs — one for
// UTXOs — one for collateral, one for funding. // collateral, one for funding.
let utxos = vec![ let utxos = vec![
InputUtxo { InputUtxo {
tx_hash_hex: "cafebabe".repeat(8), tx_hash_hex: "cafebabe".repeat(8),
@ -541,11 +537,11 @@ mod tests {
assert!(witness.redeemer.is_some(), "redeemer witness present"); assert!(witness.redeemer.is_some(), "redeemer witness present");
} }
/// AUDIT4-2 regression. A wallet with one tiny qualifying UTXO /// A wallet with one tiny qualifying UTXO alongside one huge UTXO
/// alongside one huge UTXO must pick the tiny one for collateral /// must pick the tiny one for collateral and the huge one for
/// and the huge one for funding (not the inverse). The inverse /// funding (not the inverse). The inverse fails in the common
/// fails in the common wallet shape where funding then can't /// wallet shape where funding then can't cover payout + script-
/// cover payout + script-exec fee + change min_utxo. /// exec fee + change min_utxo.
#[test] #[test]
fn picks_smallest_qualifying_collateral_largest_funding() { fn picks_smallest_qualifying_collateral_largest_funding() {
let payment = payment_from_canonical(); let payment = payment_from_canonical();

View file

@ -44,8 +44,7 @@ pub fn add_witness(payment_key: &PaymentKey, cbor_bytes: &[u8]) -> Result<Vec<u8
// encoder). // encoder).
let body_hash = tx.transaction_body.compute_hash(); let body_hash = tx.transaction_body.compute_hash();
// M-1 audit fix: stack copy gets zeroized after from_bytes // Zeroize the stack copy after from_bytes consumes it.
// consumes it.
let mut extended_bytes: [u8; 64] = payment_key.xprv().extended_secret_key(); let mut extended_bytes: [u8; 64] = payment_key.xprv().extended_secret_key();
let secret = SecretKeyExtended::from_bytes(extended_bytes) let secret = SecretKeyExtended::from_bytes(extended_bytes)
.map_err(|e| WalletError::Derivation(format!("invalid extended secret: {e}"))); .map_err(|e| WalletError::Derivation(format!("invalid extended secret: {e}")));

View file

@ -127,10 +127,9 @@ impl ProtocolParams {
.saturating_add(self.min_fee_b) .saturating_add(self.min_fee_b)
} }
/// ExUnits execution cost in lovelace, ceiling-rounded per the /// ExUnits execution cost in lovelace, ceiling-rounded per Conway
/// Conway protocol rules. Add this to `min_fee_for_size(...)` for /// protocol rules. Add this to `min_fee_for_size(...)` for the
/// the total fee on a Plutus transaction. /// total fee on a Plutus transaction.
/// (PLUTUS-3 audit fix.)
pub fn ex_units_fee(&self, mem: u64, steps: u64) -> u64 { pub fn ex_units_fee(&self, mem: u64, steps: u64) -> u64 {
// ceil(mem * num / den) // ceil(mem * num / den)
let mem_cost = mem let mem_cost = mem
@ -341,10 +340,10 @@ fn hex_decode_32(s: &str) -> Result<[u8; 32], WalletError> {
/// `BuiltTransaction::sign` can consume it. The XPrv's first 64 /// `BuiltTransaction::sign` can consume it. The XPrv's first 64
/// bytes are the extended secret; we reuse them directly. /// bytes are the extended secret; we reuse them directly.
/// ///
/// **M-1 audit fix**: defensive zeroize of the stack-resident /// Defensive zeroize of the stack-resident extended-secret bytes
/// extended-secret bytes after `from_bytes` consumes them. The /// after `from_bytes` consumes them. The SecretKeyExtended itself
/// SecretKeyExtended itself zeroizes on drop (pallas-crypto handles /// zeroizes on drop (pallas-crypto handles that); this just covers
/// that); this just covers the local stack copy that lingers between /// the local stack copy that lingers between
/// `extended_secret_key()` returning and `from_bytes` taking it by /// `extended_secret_key()` returning and `from_bytes` taking it by
/// value. /// value.
fn payment_key_to_private(payment: &PaymentKey) -> Result<PrivateKey, WalletError> { fn payment_key_to_private(payment: &PaymentKey) -> Result<PrivateKey, WalletError> {
@ -502,10 +501,10 @@ fn build_unsigned_bytes(staging: StagingTransaction) -> Result<Vec<u8>, WalletEr
/// `&[]` 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 /// `to_inline_datum_cbor`, when `Some`, attaches the bytes as an
/// inline datum on the recipient output. Used to lock funds at a /// inline datum on the recipient output — needed to lock funds at a
/// script address with a datum the validator can read (AUDIT4-3 /// script address with a datum the validator can read. The change
/// fix). Change output never gets a datum — it goes back to the /// output never gets a datum (it goes back to the wallet, no
/// wallet which has no validator to satisfy. /// validator to satisfy).
/// ///
/// `to_reference_script`, when `Some`, attaches the script bytes as /// `to_reference_script`, when `Some`, attaches the script bytes as
/// a reference-script on the recipient output (Babbage/Conway era /// a reference-script on the recipient output (Babbage/Conway era
@ -622,8 +621,8 @@ fn prepare_payment(
// worth of lovelace into change in that case. // worth of lovelace into change in that case.
let change_must_exist = !change_assets.is_empty(); let change_must_exist = !change_assets.is_empty();
// L-1 audit fix: checked_add for the inner sum so the outer // checked_add on the inner sum so the outer checked_sub is fully
// checked_sub is fully defensive against u64 overflow. // defensive against u64 overflow.
let outflow = lovelace let outflow = lovelace
.checked_add(real_fee) .checked_add(real_fee)
.ok_or_else(|| WalletError::Derivation("lovelace + fee overflow".into()))?; .ok_or_else(|| WalletError::Derivation("lovelace + fee overflow".into()))?;
@ -671,9 +670,8 @@ fn prepare_payment(
// Re-shape the asset maps back into Vec<AssetSpec> for the // Re-shape the asset maps back into Vec<AssetSpec> for the
// summary — easier for callers to display than a BTreeMap. // summary — easier for callers to display than a BTreeMap.
// L-4 audit fix: replaced .expect() with proper error // Logic-bug paths (we built the key) propagate as typed errors
// propagation. Logic-bug paths (we built the key) become // rather than panicking the process.
// typed errors instead of process-level panics.
let send_assets_vec: Vec<AssetSpec> = target_assets let send_assets_vec: Vec<AssetSpec> = target_assets
.iter() .iter()
.map(|(k, v)| { .map(|(k, v)| {
@ -767,8 +765,8 @@ pub fn build_signed_payment(
/// ///
/// `to_inline_datum_cbor`, when `Some`, attaches the bytes as an /// `to_inline_datum_cbor`, when `Some`, attaches the bytes as an
/// inline datum on the recipient output — needed to lock funds at /// inline datum on the recipient output — needed to lock funds at
/// a script address with a datum the validator can read (AUDIT4-3 /// a script address with a datum the validator can read. `None`
/// fix). `None` for normal sends to wallet addresses. /// 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,

View file

@ -89,16 +89,12 @@ pub struct UnsignedEscrowOpen {
pub fn build_unsigned_escrow_open(args: EscrowOpenArgs) -> DaoResult<UnsignedEscrowOpen> { pub fn build_unsigned_escrow_open(args: EscrowOpenArgs) -> DaoResult<UnsignedEscrowOpen> {
// ---- preflight ---- // ---- preflight ----
// //
// HIGH-2 fix (2026-05-09 audit): refuse `initial_contributor=None` // Every escrow needs at least one contributor at open. With
// entirely. Previously this opened an escrow with `deposits=[]` and // `deposits=[]` the validator's `refund_outputs_satisfy(_, [])`
// `initial_lovelace > 0`, which the validator (pre-fix) treated as // returns vacuously true (drainable on Veto/Refund), and even
// a refundable-by-anyone escrow because `refund_outputs_satisfy(_, [])` // post-fix the empty-deposits path produces a permanently-stuck
// is vacuously true. Even with the validator now enforcing // escrow until someone deposits. Cleaner invariant: party_a can
// `sum(deposits) == in_value`, the empty-deposits path produces a // just open + deposit in a single call.
// permanently-stuck escrow (no Veto/Refund possible until somebody
// deposits). Cleaner v1 invariant: every escrow has at least one
// contributor at open. The "open empty, top up later" UX was never
// useful anyway — party_a can just open + deposit in a single call.
let initial_contributor = args.initial_contributor.ok_or_else(|| { let initial_contributor = args.initial_contributor.ok_or_else(|| {
DaoError::State( DaoError::State(
"initial_contributor is required — open an escrow with at least one contributor (party_a or party_b)" "initial_contributor is required — open an escrow with at least one contributor (party_a or party_b)"
@ -111,12 +107,11 @@ pub fn build_unsigned_escrow_open(args: EscrowOpenArgs) -> DaoResult<UnsignedEsc
)); ));
} }
// MED-5 fix (2026-05-09 audit): the escrow output bears an inline // The escrow output bears an inline datum + sits at a script
// datum + sits at a script address — Conway-era min-utxo computes // address — Conway-era min-utxo computes to ~1.4-1.7 ADA depending
// to ~1.4-1.7 ADA depending on datum size, NOT the 1 ADA default. // on datum size, not the 1 ADA default. Floor matches the
// Bump our floor to match the deposit/spend builders' constant // deposit/spend builders' 2 ADA constant so an open tx that
// (2 ADA) so an open tx that passes preflight also passes // passes preflight also passes pallas-txbuilder's min-utxo check.
// pallas-txbuilder's actual min-utxo computation.
const ESCROW_OPEN_MIN_LOVELACE: u64 = 2_000_000; const ESCROW_OPEN_MIN_LOVELACE: u64 = 2_000_000;
if args.initial_lovelace < ESCROW_OPEN_MIN_LOVELACE { if args.initial_lovelace < ESCROW_OPEN_MIN_LOVELACE {
return Err(DaoError::State(format!( return Err(DaoError::State(format!(
@ -127,9 +122,8 @@ pub fn build_unsigned_escrow_open(args: EscrowOpenArgs) -> DaoResult<UnsignedEsc
// ---- build the datum ---- // ---- build the datum ----
// //
// initial_contributor is now mandatory (see HIGH-2 fix above). Build // Build the EscrowValue mirroring what's actually paid into the
// the EscrowValue mirroring what's actually paid into the script // script output: lovelace plus any native assets.
// output: lovelace + any native assets.
let mut value = EscrowValue::ada(args.initial_lovelace); let mut value = EscrowValue::ada(args.initial_lovelace);
for a in &args.initial_assets { for a in &args.initial_assets {
let policy = hex::decode(&a.policy_id_hex) let policy = hex::decode(&a.policy_id_hex)
@ -197,10 +191,9 @@ mod tests {
#[test] #[test]
fn rejects_no_initial_contributor() { fn rejects_no_initial_contributor() {
// HIGH-2 fix: opening an escrow with `None` initial_contributor // Opening with `None` is refused — a deposits=[] escrow used
// is now refused (formerly produced a deposits=[] escrow that // to be drainable on Veto / Refund via vacuous-true
// could be drained on Veto / Refund via vacuous-true // refund_outputs_satisfy.
// refund_outputs_satisfy).
let args = EscrowOpenArgs { let args = EscrowOpenArgs {
network: Network::Preprod, network: Network::Preprod,
escrow_script_address: "addr_test1wpyt48l...".to_string(), escrow_script_address: "addr_test1wpyt48l...".to_string(),

View file

@ -44,6 +44,5 @@ rpassword = { workspace = true }
zeroize = { workspace = true } zeroize = { workspace = true }
[dev-dependencies] [dev-dependencies]
# CRIT-1/CRIT-2 audit fix tests (2026-05-12): need a real tx CBOR # Sandbox + cap-check tests need a temp-dir + tx CBOR fixture.
# fixture + temp-dir sandbox root.
tempfile = "3" tempfile = "3"

View file

@ -31,10 +31,9 @@ use anyhow::{anyhow, Context, Result};
use zeroize::Zeroizing; use zeroize::Zeroizing;
/// Atomically create a file with `0o600` permissions and write the /// Atomically create a file with `0o600` permissions and write the
/// payload. Replaces the older `fs::write` + `chmod` two-step which /// payload. Avoids the `fs::write` + `chmod` TOCTOU window where the
/// had a TOCTOU window where the file existed with default umask /// file briefly exists at default umask (often `0o644`) before the
/// perms (often `0o644`) before the chmod tightened it. /// chmod tightens it.
/// (M-2 audit fix.)
#[cfg(unix)] #[cfg(unix)]
fn write_owner_only(path: &Path, payload: &[u8]) -> Result<()> { fn write_owner_only(path: &Path, payload: &[u8]) -> Result<()> {
use std::os::unix::fs::OpenOptionsExt; use std::os::unix::fs::OpenOptionsExt;
@ -55,9 +54,8 @@ fn write_owner_only(path: &Path, payload: &[u8]) -> Result<()> {
} }
/// Read `ALDABRA_PASSPHRASE` env into a `Zeroizing<String>` so the /// Read `ALDABRA_PASSPHRASE` env into a `Zeroizing<String>` so the
/// in-process copy gets wiped when dropped. The env block itself /// in-process copy gets wiped on drop. The env block itself isn't
/// isn't zeroizable — that's a documented headless tradeoff. (M-3 /// zeroizable — documented headless tradeoff.
/// audit fix.)
fn passphrase_from_env() -> Option<Zeroizing<String>> { fn passphrase_from_env() -> Option<Zeroizing<String>> {
std::env::var("ALDABRA_PASSPHRASE").ok().map(Zeroizing::new) std::env::var("ALDABRA_PASSPHRASE").ok().map(Zeroizing::new)
} }

View file

@ -3,20 +3,8 @@
//! Speaks MCP over stdio. Any MCP client (Claude Code, OpenClaw, etc.) //! Speaks MCP over stdio. Any MCP client (Claude Code, OpenClaw, etc.)
//! launches this as a subprocess and gets a wallet's worth of tools. //! launches this as a subprocess and gets a wallet's worth of tools.
//! //!
//! ## Phase 1 tools (target — server wiring lands in 1.7) //! Logging: stderr only — stdout is the MCP transport and must stay
//! //! clean.
//! - `wallet.address` — derived CIP-1852 base address
//! - `wallet.balance` — ADA + native-asset balance via chain backend
//! - `wallet.utxos` — list UTXOs at the wallet address
//! - `wallet.network` — configured network selector
//!
//! ## Phase 2-4 tools
//!
//! See `ROADMAP.md` at the repo root.
//!
//! ## Logging
//!
//! Stderr only — stdout is the MCP transport, must stay clean.
mod bootstrap; mod bootstrap;
mod config; mod config;
@ -60,17 +48,12 @@ async fn run() -> Result<()> {
"aldabra starting" "aldabra starting"
); );
// CRIT-2 audit fix (2026-05-12): make sure the sandbox root exists // Make sure the sandbox root exists and is daemon-only readable.
// and is daemon-only readable. Tools use canonicalize() against // Tools canonicalize() against this dir to validate `*_path`
// this dir to validate `*_path` args, which requires the dir to // args, which requires the dir to be a real directory on disk.
// be a real directory on disk. Idempotent: create_dir_all is a // Chmod is `?`'d not swallowed — if the filesystem refuses it
// no-op when the dir already exists. // (noexec, selinux, broken mount), fail loudly instead of
// // silently falling back to umask 0o755.
// Adversarial-review fix (2026-05-12): chmod is `?`'d not swallowed.
// If the filesystem refuses chmod (noexec, selinux, broken mount),
// we'd otherwise silently fall back to the umask default (commonly
// 0o755) — making the security comment "daemon-only readable" a
// lie. Fail loudly instead so the operator sees + investigates.
if !cfg.safe_reads_root.exists() { if !cfg.safe_reads_root.exists() {
std::fs::create_dir_all(&cfg.safe_reads_root).map_err(|e| { std::fs::create_dir_all(&cfg.safe_reads_root).map_err(|e| {
anyhow::anyhow!( anyhow::anyhow!(
@ -117,8 +100,8 @@ async fn run() -> Result<()> {
let xprv_path = bootstrap::root_xprv_path(&cfg.data_dir); let xprv_path = bootstrap::root_xprv_path(&cfg.data_dir);
let any_key_exists = mnemonic_path.exists() || xprv_path.exists(); let any_key_exists = mnemonic_path.exists() || xprv_path.exists();
// L-2 audit fix: scope `root` to a block so its XPrv drops + wipes // Scope `root` to a block so its XPrv drops + wipes as soon as
// as soon as we've extracted the keys we need. // we've extracted the keys we need.
let (payment_key, stake_key, address) = { let (payment_key, stake_key, address) = {
let root = if bootstrap_new { let root = if bootstrap_new {
bootstrap::generate_and_save_root_key(&cfg.data_dir)? bootstrap::generate_and_save_root_key(&cfg.data_dir)?

View file

@ -5,23 +5,9 @@
//! tool names against `[a-zA-Z0-9_-]{1,64}` and silently drops names //! tool names against `[a-zA-Z0-9_-]{1,64}` and silently drops names
//! with dots, causing the daemon to run without advertising any tools. //! with dots, causing the daemon to run without advertising any tools.
//! //!
//! ## Phase 1 — read path //! Result types: `String` passes through `IntoContents` directly;
//! //! `Result<String, String>` surfaces chain / build errors as MCP
//! - `wallet_address` — bech32 base address //! tool-call errors instead of crashing the daemon.
//! - `wallet_network` — mainnet | preview | preprod
//! - `wallet_balance` — JSON `{lovelace, assets}`
//! - `wallet_utxos` — JSON list of UTXOs
//!
//! ## Phase 2 — send path
//!
//! - `wallet_send` — build + sign + submit ADA payment, with hard
//! cap guard (`max_send_lovelace`)
//! - `wallet_tx_status` — poll a submitted tx hash
//!
//! Returns:
//! - `String` results pass through `IntoContents` directly.
//! - `Result<String, String>` lets us surface chain / build errors
//! as MCP tool-call errors instead of crashing the daemon.
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
@ -78,29 +64,17 @@ use aldabra_dao::discovery::{
}; };
use aldabra_dao::reader::{DaoReader, KoiosDaoReader}; use aldabra_dao::reader::{DaoReader, KoiosDaoReader};
/// CRIT-2 audit fix (2026-05-12): reject any `*_path` arg whose /// Reject any `*_path` arg whose canonical form does not live under
/// canonical form does not live under the configured sandbox root. /// the sandbox root. Without this, a prompt-injection could point the
/// Without this guard, a prompt-injection that gets the LLM to call a /// daemon at `$ALDABRA_DATA/root-xprv.age` and leak key bytes through
/// tool with `reference_script_path: "/var/lib/aldabra/root-xprv.age"` /// decode-error messages. Both root and arg are canonicalized
/// would have the daemon happily read the encrypted key blob. Even /// (symlinks resolved, must exist); arg must `starts_with` root.
/// though hex decoding will fail, the previous error message included
/// the byte offset of the first non-hex character — a small but real
/// information leak on the file's binary structure. This guard +
/// content-stripped error messages close both surfaces.
/// ///
/// `safe_reads_root` and `user_path` are both canonicalized (resolves /// Hardlinks are refused too: `canonicalize` resolves symlinks but a
/// symlinks, requires the file to exist). The user path must /// hardlink IS the file. If an attacker with daemon-uid write access
/// `starts_with` the root; equality is allowed but rare in practice. /// plants a hardlink to a secret inside the sandbox, the inode is
/// /// inside-sandbox by every filesystem-level check. Nothing legitimate
/// Adversarial-review fix (2026-05-12): also reject regular files /// puts hardlinks in a script-CBOR dir, so `nlink > 1` → refuse.
/// whose link count > 1. `canonicalize` resolves symlinks but NOT
/// hardlinks — a hardlink IS the file (same inode, multiple directory
/// entries). Without this check, an attacker with daemon-uid write
/// access could plant a hardlink inside the sandbox pointing at the
/// inode of `$ALDABRA_DATA/root-xprv.age` and exfiltrate key bytes
/// through the read path. No legitimate use case puts hardlinks in
/// a script-CBOR sandbox; refusing them outright is conservative
/// and easy.
fn assert_inside_sandbox(safe_reads_root: &Path, user_path: &str) -> Result<PathBuf, String> { fn assert_inside_sandbox(safe_reads_root: &Path, user_path: &str) -> Result<PathBuf, String> {
let user_canon = std::fs::canonicalize(user_path) let user_canon = std::fs::canonicalize(user_path)
.map_err(|e| format!("resolve path: {e} (path must exist + be readable)"))?; .map_err(|e| format!("resolve path: {e} (path must exist + be readable)"))?;
@ -117,12 +91,9 @@ fn assert_inside_sandbox(safe_reads_root: &Path, user_path: &str) -> Result<Path
root_canon.display() root_canon.display()
)); ));
} }
// Hardlink rejection. `metadata` follows the post-canonicalize path // nlink > 1 means another directory entry points at the same inode.
// directly to the file (no symlink traversal needed since canonicalize // We can't verify the other entry is also inside the sandbox without
// already resolved them). nlink > 1 means another directory entry // a full filesystem scan, so reject conservatively.
// points at the same inode; we can't verify the other entry is also
// inside the sandbox without an exhaustive filesystem scan, so we
// reject conservatively.
#[cfg(unix)] #[cfg(unix)]
{ {
use std::os::unix::fs::MetadataExt; use std::os::unix::fs::MetadataExt;
@ -139,24 +110,16 @@ fn assert_inside_sandbox(safe_reads_root: &Path, user_path: &str) -> Result<Path
Ok(user_canon) Ok(user_canon)
} }
/// Resolve a reference-script bytestring from EITHER an inline hex /// Resolve a reference-script bytestring from either an inline hex
/// argument OR a file path inside the container. Caller passes both /// argument or a file path. At most one may be set.
/// raw options; this fn enforces the "at most one" rule and reads
/// the file when path is set.
/// ///
/// The path-based variant exists because of an MCP transport bug: /// The path variant exists because MCP truncates hex strings over
/// hex strings >~ 4500 chars get a 1-byte truncation + structural /// ~4500 chars somewhere between client and stdio reader. Reading
/// rearrangement somewhere between client and stdio reader. Reading /// from a file bypasses the JSON-RPC arg path entirely.
/// from a file inside the container bypasses the JSON-RPC arg path
/// entirely.
/// ///
/// CRIT-2 audit fix (2026-05-12): the path arg is sandboxed via /// Path reads are sandboxed by [`assert_inside_sandbox`]. Decode
/// [`assert_inside_sandbox`] against the daemon's configured /// errors on path contents return a constant message — no byte-offset
/// `safe_reads_root` (default `$ALDABRA_DATA/scripts/`). Decode-error /// or file-content leak.
/// messages no longer include the file path or byte-offset context —
/// only "could not decode as hex" — to avoid leaking structural
/// information about non-hex files (e.g. encrypted key blobs that an
/// attacker might point the tool at).
fn resolve_ref_script_bytes( fn resolve_ref_script_bytes(
safe_reads_root: &Path, safe_reads_root: &Path,
cbor_hex: Option<&str>, cbor_hex: Option<&str>,
@ -180,10 +143,8 @@ fn resolve_ref_script_bytes(
if cleaned.is_empty() { if cleaned.is_empty() {
return Err("reference_script_path contained no hex characters".into()); return Err("reference_script_path contained no hex characters".into());
} }
// CRIT-2: do NOT include the hex_decode error's position- // Constant message — leaking byte offsets would narrow an
// offset detail or the file's contents in the user-visible // attacker's guesses about file structure.
// error. A failed decode is a yes/no signal; anything more
// narrows the attacker's guesses about file structure.
hex_decode(&cleaned) hex_decode(&cleaned)
.map(Some) .map(Some)
.map_err(|_| "reference_script_path contents are not valid hex".to_string()) .map_err(|_| "reference_script_path contents are not valid hex".to_string())
@ -192,19 +153,10 @@ fn resolve_ref_script_bytes(
} }
} }
/// Resolve the Plutus minting-policy CBOR from EITHER an inline /// Resolve a Plutus minting-policy CBOR from either inline hex or
/// hex argument OR a file path inside the container. Caller passes /// a file path. Exactly one must be set. Mirrors
/// both raw options; this fn enforces the "exactly one" rule and /// [`resolve_ref_script_bytes`] — same transport workaround, same
/// reads the file when path is set. /// sandbox + constant-message contract.
///
/// Mirrors [`resolve_ref_script_bytes`] — same workaround for the
/// MCP large-string transport bug where hex strings >~ 4500 chars
/// get a 1-byte truncation between client and stdio reader,
/// surfacing as "odd length" hex decode errors and blocking debug-
/// build minting policies. Reading from a file inside the container
/// bypasses the JSON-RPC arg path entirely.
///
/// CRIT-2 audit fix (2026-05-12): see [`resolve_ref_script_bytes`].
fn resolve_policy_cbor_bytes( fn resolve_policy_cbor_bytes(
safe_reads_root: &Path, safe_reads_root: &Path,
cbor_hex: Option<&str>, cbor_hex: Option<&str>,
@ -325,10 +277,9 @@ struct WalletInner {
/// quotas. None = public tier. Sourced from env only — never from /// quotas. None = public tier. Sourced from env only — never from
/// disk; never logged. /// disk; never logged.
koios_bearer: Option<String>, koios_bearer: Option<String>,
/// CRIT-2 audit fix (2026-05-12): sandbox root for `*_path` tool /// Sandbox root for `*_path` tool args. Files outside this dir
/// args. Files outside this directory (canonical) are refused to /// (canonical) are refused, keeping prompt-injection from steering
/// keep prompt-injection from steering reads at the encrypted /// reads at the encrypted key material under `$ALDABRA_DATA/`.
/// key material under `$ALDABRA_DATA/`.
safe_reads_root: PathBuf, safe_reads_root: PathBuf,
} }
@ -386,10 +337,9 @@ impl WalletService {
} }
/// Reject if `lovelace` exceeds the wallet's hard cap unless /// Reject if `lovelace` exceeds the wallet's hard cap unless
/// `force=true`. Used by every tool that moves lovelace to a /// `force=true`. Every tool that moves lovelace to a non-wallet
/// non-wallet destination — wallet_send, wallet_mint, /// destination calls this — `wallet_send`, `wallet_mint`,
/// wallet_mint_cip68_nft, wallet_script_spend. /// `wallet_mint_cip68_nft`, `wallet_script_spend`.
/// (HIGH-1 audit fix: previously only wallet_send had this guard.)
fn enforce_value_cap(&self, lovelace: u64, force: bool) -> Result<(), String> { fn enforce_value_cap(&self, lovelace: u64, force: bool) -> Result<(), String> {
if lovelace > self.inner.max_send_lovelace && !force { if lovelace > self.inner.max_send_lovelace && !force {
return Err(format!( return Err(format!(
@ -400,23 +350,16 @@ impl WalletService {
Ok(()) Ok(())
} }
/// CRIT-1 audit fix (2026-05-12): scan a Conway-era tx CBOR and /// Chokepoint for `wallet_sign_partial` and `wallet_submit_signed_tx`:
/// enforce the wallet's `max_send_lovelace` cap against the sum /// sum lovelace going to any non-self address and apply
/// of lovelace going to any address that isn't this wallet's own. /// `enforce_value_cap`. Without this, a prompt-injection can walk
/// This is the chokepoint that defends `wallet_sign_partial` and /// the unsigned → sign → submit chain and drain past the cap —
/// `wallet_submit_signed_tx` against an unsigned-tx → /// each step alone only checks `lovelace > 0`.
/// sign-partial → submit drain via prompt injection. Each of
/// those tools individually had `lovelace > 0` validation but no
/// blast-radius limit.
/// ///
/// "Non-self" is computed by comparing the wallet's bech32 address /// "Non-self" matches the wallet's primary bech32 address only.
/// (decoded to raw bytes via pallas-addresses, then hexed) against /// Other accounts/indexes of the same wallet are conservatively
/// each output's `address_hex` in the tx body. Outputs whose /// counted as non-self. `force=true` overrides, same contract as
/// address matches are treated as change/self-send and excluded. /// `enforce_value_cap`.
/// This is intentionally conservative on the same wallet's other
/// accounts/indexes — they'll be flagged as non-self until/unless
/// the wallet learns additional addresses to consider its own.
/// Force `=true` overrides; mirror the `enforce_value_cap` contract.
fn enforce_cap_on_cbor(&self, cbor_bytes: &[u8], force: bool) -> Result<(), String> { fn enforce_cap_on_cbor(&self, cbor_bytes: &[u8], force: bool) -> Result<(), String> {
let total = sum_non_self_lovelace(cbor_bytes, &self.inner.address)?; let total = sum_non_self_lovelace(cbor_bytes, &self.inner.address)?;
self.enforce_value_cap(total, force) self.enforce_value_cap(total, force)
@ -428,13 +371,10 @@ impl WalletService {
/// function so the cap-check logic is unit-testable without standing /// function so the cap-check logic is unit-testable without standing
/// up a full WalletService. /// up a full WalletService.
/// ///
/// Adversarial-review fix (2026-05-12): `try_fold` + `checked_add` /// Uses `try_fold` + `checked_add` rather than `.sum::<u64>()` so a
/// instead of `.sum::<u64>()`. The naive `.sum()` wraps on overflow /// crafted CBOR that overflows can't silently wrap to a small value
/// in release builds — a prompt-injection could craft a CBOR with /// in release builds. Cardano max supply is ~400x below `u64::MAX`
/// outputs summing past `u64::MAX`, wrap to a small value, and /// so the overflow path is unreachable in practice, but free to harden.
/// silently pass the cap check. Real-world unreachable (Cardano max
/// supply is ~45B ADA = 4.5e16 lovelace, ~400x below `u64::MAX`) but
/// trivial to harden.
fn sum_non_self_lovelace(cbor_bytes: &[u8], wallet_bech32: &str) -> Result<u64, String> { fn sum_non_self_lovelace(cbor_bytes: &[u8], wallet_bech32: &str) -> Result<u64, String> {
use pallas_addresses::Address; use pallas_addresses::Address;
let wallet_addr = let wallet_addr =
@ -587,10 +527,9 @@ pub struct UnsignedSendArgs {
/// [`SendArgs::reference_script_kind`]. /// [`SendArgs::reference_script_kind`].
#[serde(default)] #[serde(default)]
pub reference_script_kind: Option<String>, pub reference_script_kind: Option<String>,
/// CRIT-1 audit fix (2026-05-12): bypass the configured /// Bypass the `max_send_lovelace` cap. Required when an unsigned
/// `max_send_lovelace` hard cap. Required to build an unsigned /// tx would otherwise drain the wallet past the cap once signed
/// tx that would otherwise drain the wallet past the cap once /// and submitted. Mirrors `wallet_send.force`.
/// signed + submitted. Mirrors `wallet_send.force`.
#[serde(default)] #[serde(default)]
pub force: bool, pub force: bool,
} }
@ -601,12 +540,10 @@ pub struct SubmitSignedArgs {
/// cold-signer that consumed the unsigned CBOR returned by /// cold-signer that consumed the unsigned CBOR returned by
/// `wallet_send_unsigned`. /// `wallet_send_unsigned`.
pub signed_cbor_hex: String, pub signed_cbor_hex: String,
/// CRIT-1 audit fix (2026-05-12): bypass the configured /// Bypass the `max_send_lovelace` cap. The submit path scans the
/// `max_send_lovelace` hard cap. The submit path scans the tx /// tx CBOR and sums lovelace going to any non-self address; pass
/// CBOR and sums lovelace going to any non-self address; pass /// `force=true` for cold-signed multi-sig flows where a treasury
/// `force=true` to push a tx that exceeds that cap (cold-signed /// moves real value intentionally.
/// multi-sig flows where a treasury moves real value
/// intentionally).
#[serde(default)] #[serde(default)]
pub force: bool, pub force: bool,
} }
@ -679,10 +616,9 @@ pub struct PlutusMintUnsignedArgs {
pub ex_units_mem: Option<u64>, pub ex_units_mem: Option<u64>,
#[serde(default)] #[serde(default)]
pub ex_units_steps: Option<u64>, pub ex_units_steps: Option<u64>,
/// CRIT-1 audit fix (2026-05-12): bypass the configured /// Bypass the `max_send_lovelace` cap on `dest_lovelace`. Needed
/// `max_send_lovelace` hard cap on `dest_lovelace`. Required for /// for large script-bootstrap mints (governor / stakes / proposal
/// large script-bootstrap mints (governor / stakes / proposal /// outputs that carry sizable min-utxo + datum overhead).
/// outputs that carry sizeable min-utxo + datum overhead).
#[serde(default)] #[serde(default)]
pub force: bool, pub force: bool,
} }
@ -721,8 +657,7 @@ pub struct MintUnsignedArgs {
/// optional metadata for downstream signers. /// optional metadata for downstream signers.
#[serde(default)] #[serde(default)]
pub disclosed_signer_pkh_hex: Option<String>, pub disclosed_signer_pkh_hex: Option<String>,
/// CRIT-1 audit fix (2026-05-12): bypass the configured /// Bypass the `max_send_lovelace` cap on `dest_lovelace`. Mirrors
/// `max_send_lovelace` hard cap on `dest_lovelace`. Mirrors
/// `wallet_mint.force`. /// `wallet_mint.force`.
#[serde(default)] #[serde(default)]
pub force: bool, pub force: bool,
@ -845,12 +780,10 @@ pub struct SignPartialArgs {
/// partially signed by another party. The wallet's payment key /// partially signed by another party. The wallet's payment key
/// gets appended as an additional VKeyWitness. /// gets appended as an additional VKeyWitness.
pub cbor_hex: String, pub cbor_hex: String,
/// CRIT-1 audit fix (2026-05-12): bypass the configured /// Bypass the `max_send_lovelace` cap. Before signing, the tool
/// `max_send_lovelace` hard cap. Before signing, the tool sums /// sums lovelace across every non-self output in the CBOR; pass
/// lovelace across every non-self output in the CBOR (anything /// `force=true` for cold-signed multi-sig flows where a treasury
/// not addressed to this wallet) and rejects if the total /// moves real value intentionally.
/// exceeds the cap. Pass `force=true` for cold-signed multi-sig
/// flows where a treasury moves real value intentionally.
#[serde(default)] #[serde(default)]
pub force: bool, pub force: bool,
} }
@ -1016,12 +949,11 @@ impl WalletService {
if lovelace == 0 { if lovelace == 0 {
return Err("lovelace must be > 0".into()); return Err("lovelace must be > 0".into());
} }
// AUDIT4-G2 fix: catch sub-min-utxo ada-only sends client-side // Catch sub-min-utxo ada-only sends client-side so the user
// before the chain rejects them (saves a koios round-trip + the // gets a clean error instead of "tx submitted" → "tx failed
// user's mental model of "tx submitted" → "tx failed minutes later"). // minutes later". Asset-bearing sends have a dynamic min
// Asset-bearing sends have a dynamic min driven by asset count + // (asset count + name length) — let those reach chain so
// name lengths — let those reach chain so the real number is in // the real number is in the rejection.
// the error.
let default_min = ProtocolParams::default().min_utxo_lovelace; let default_min = ProtocolParams::default().min_utxo_lovelace;
if assets.is_empty() && lovelace < default_min { if assets.is_empty() && lovelace < default_min {
return Err(format!( return Err(format!(
@ -1142,10 +1074,8 @@ impl WalletService {
if lovelace == 0 { if lovelace == 0 {
return Err("lovelace must be > 0".into()); return Err("lovelace must be > 0".into());
} }
// CRIT-1 audit fix (2026-05-12): apply the same cap as wallet_send // Same cap as wallet_send so the unsigned-build chain isn't a
// so the unsigned-build chain isn't a quiet bypass of the daemon's // quiet bypass. Sign + submit re-check via enforce_cap_on_cbor.
// value-safety policy. Defense-in-depth — the sign / submit
// tools also re-check via enforce_cap_on_cbor.
self.enforce_value_cap(lovelace, force)?; self.enforce_value_cap(lovelace, force)?;
let utxos = self let utxos = self
@ -1225,11 +1155,9 @@ impl WalletService {
}: SubmitSignedArgs, }: SubmitSignedArgs,
) -> Result<String, String> { ) -> Result<String, String> {
let bytes = hex_decode(&signed_cbor_hex).map_err(|e| format!("decode: {e}"))?; let bytes = hex_decode(&signed_cbor_hex).map_err(|e| format!("decode: {e}"))?;
// CRIT-1 audit fix (2026-05-12): scan the tx for outputs going // Sum non-self outputs and enforce the cap. Closes the prompt-
// to non-self addresses and enforce the cap on the sum. Closes // injection drain path through hand-crafted + signed CBOR that
// the prompt-injection drain path where an attacker convinces // bypasses the per-tool wallet_send cap.
// the LLM to hand-craft + sign + submit a tx that bypasses the
// cap on the individual wallet_send tool.
self.enforce_cap_on_cbor(&bytes, force)?; self.enforce_cap_on_cbor(&bytes, force)?;
self.inner self.inner
.chain .chain
@ -1293,8 +1221,6 @@ impl WalletService {
"dest_lovelace {dest_lovelace} below 1 ADA min — token-bearing UTXO will be rejected" "dest_lovelace {dest_lovelace} below 1 ADA min — token-bearing UTXO will be rejected"
)); ));
} }
// HIGH-1 audit fix: enforce hard cap on lovelace going to a
// potentially-non-wallet destination.
self.enforce_value_cap(dest_lovelace, force)?; self.enforce_value_cap(dest_lovelace, force)?;
let utxos = self let utxos = self
@ -1369,9 +1295,7 @@ impl WalletService {
if user_lovelace < 1_000_000 || ref_lovelace < 1_000_000 { if user_lovelace < 1_000_000 || ref_lovelace < 1_000_000 {
return Err("user_lovelace and ref_lovelace must each be ≥ 1 ADA".into()); return Err("user_lovelace and ref_lovelace must each be ≥ 1 ADA".into());
} }
// HIGH-1: cap on the total lovelace that leaves the wallet // Cap the total lovelace leaving the wallet (both outputs).
// toward non-self destinations. Sum the two outputs; if either
// overflows, also reject.
let total = user_lovelace let total = user_lovelace
.checked_add(ref_lovelace) .checked_add(ref_lovelace)
.ok_or("user_lovelace + ref_lovelace overflow")?; .ok_or("user_lovelace + ref_lovelace overflow")?;
@ -1457,8 +1381,6 @@ impl WalletService {
force, force,
}: ScriptSpendArgs, }: ScriptSpendArgs,
) -> Result<String, String> { ) -> Result<String, String> {
// HIGH-1: cap on payout_lovelace (the funds going to the
// potentially-non-wallet payout address).
self.enforce_value_cap(payout_lovelace, force)?; self.enforce_value_cap(payout_lovelace, force)?;
let version = match plutus_version.to_ascii_lowercase().as_str() { let version = match plutus_version.to_ascii_lowercase().as_str() {
"v1" => PlutusVersion::V1, "v1" => PlutusVersion::V1,
@ -1511,10 +1433,9 @@ impl WalletService {
lovelace: locked_lovelace, lovelace: locked_lovelace,
}; };
// Plutus paths require the V3 cost model so the chain can // Plutus paths need the V3 cost model so the chain can verify
// verify script_data_hash. Hardcoded preprod value from // script_data_hash. Hardcoded preprod value from koios
// koios epoch_params; future improvement is pulling fresh // epoch_params; eventually fetch fresh per call.
// from the chain on each call. (PLUTUS-4 audit fix.)
let params = ProtocolParams { let params = ProtocolParams {
plutus_v3_cost_model: Some(PLUTUS_V3_COST_MODEL_PREPROD.to_vec()), plutus_v3_cost_model: Some(PLUTUS_V3_COST_MODEL_PREPROD.to_vec()),
..ProtocolParams::default() ..ProtocolParams::default()
@ -1839,8 +1760,6 @@ impl WalletService {
"dest_lovelace {dest_lovelace} below 1 ADA min for asset-bearing UTXO" "dest_lovelace {dest_lovelace} below 1 ADA min for asset-bearing UTXO"
)); ));
} }
// CRIT-1 audit fix (2026-05-12): cap the unsigned-mint output's
// dest_lovelace, mirroring wallet_mint.
self.enforce_value_cap(dest_lovelace, force)?; self.enforce_value_cap(dest_lovelace, force)?;
// Resolve PolicySpec — caller-supplied JSON or wallet default. // Resolve PolicySpec — caller-supplied JSON or wallet default.
@ -1928,8 +1847,6 @@ impl WalletService {
"dest_lovelace {dest_lovelace} below 1 ADA min for asset-bearing UTXO" "dest_lovelace {dest_lovelace} below 1 ADA min for asset-bearing UTXO"
)); ));
} }
// CRIT-1 audit fix (2026-05-12): cap the Plutus-mint
// dest_lovelace, mirroring wallet_mint.
self.enforce_value_cap(dest_lovelace, force)?; self.enforce_value_cap(dest_lovelace, force)?;
let policy_cbor = resolve_policy_cbor_bytes( let policy_cbor = resolve_policy_cbor_bytes(
@ -2073,11 +1990,9 @@ impl WalletService {
#[tool(aggr)] SignPartialArgs { cbor_hex, force }: SignPartialArgs, #[tool(aggr)] SignPartialArgs { cbor_hex, force }: SignPartialArgs,
) -> Result<String, String> { ) -> Result<String, String> {
let bytes = hex_decode(&cbor_hex).map_err(|e| format!("decode: {e}"))?; let bytes = hex_decode(&cbor_hex).map_err(|e| format!("decode: {e}"))?;
// CRIT-1 audit fix (2026-05-12): scan the unsigned/partial tx // Sum non-self outputs and enforce the cap BEFORE appending a
// for outputs going to non-self addresses and enforce the cap // witness — closes the prompt-injection drain through the
// on the sum BEFORE appending a witness. Closes the prompt- // unsigned-build → sign → submit chain.
// injection drain path through the unsigned-build → sign →
// submit chain (each step individually had no cap-awareness).
self.enforce_cap_on_cbor(&bytes, force)?; self.enforce_cap_on_cbor(&bytes, force)?;
let updated = let updated =
add_witness(&self.inner.payment_key, &bytes).map_err(|e| format!("sign: {e}"))?; add_witness(&self.inner.payment_key, &bytes).map_err(|e| format!("sign: {e}"))?;
@ -4110,13 +4025,12 @@ impl WalletService {
let wallet_utxos = pull_wallet_utxos(&self.inner.chain, &self.inner.address).await?; let wallet_utxos = pull_wallet_utxos(&self.inner.chain, &self.inner.address).await?;
let (tip_slot, _tip_ms) = fetch_tip_slot_ms(self).await?; let (tip_slot, _tip_ms) = fetch_tip_slot_ms(self).await?;
// MED-2/3 fix (2026-05-09 audit): derive `validity_lower_ms` // Derive `validity_lower_ms` from the slot via Shelley
// from the slot via the Shelley constants, NOT from Koios's // constants, NOT from Koios's `block_time*1000`. The chain
// `block_time*1000`. The chain reconstructs `lower` from // reconstructs `lower` from the slot via slot↔ms math; Koios's
// `valid_from_slot(slot)` via slot↔ms math; using Koios's
// block_time can drift up to ~1s, which at the strict-`>` // block_time can drift up to ~1s, which at the strict-`>`
// validator boundary makes the off-chain preflight pass while // validator boundary lets the off-chain preflight pass while
// the chain rejects → fees + collateral burned. // the chain rejects — burning fees + collateral.
let validity_lower_slot = tip_slot; let validity_lower_slot = tip_slot;
let validity_lower_ms = slot_to_posix_ms(cfg.network, validity_lower_slot)?; let validity_lower_ms = slot_to_posix_ms(cfg.network, validity_lower_slot)?;
@ -4184,7 +4098,7 @@ impl WalletService {
let cfg = self.dao_cfg_for_escrow()?; let cfg = self.dao_cfg_for_escrow()?;
let wallet_utxos = pull_wallet_utxos(&self.inner.chain, &self.inner.address).await?; let wallet_utxos = pull_wallet_utxos(&self.inner.chain, &self.inner.address).await?;
let (tip_slot, _tip_ms) = fetch_tip_slot_ms(self).await?; let (tip_slot, _tip_ms) = fetch_tip_slot_ms(self).await?;
// MED-2/3 fix: same slot-derived ms as Settle (see escrow_settle_unsigned). // Same slot-derived ms as Settle (see escrow_settle_unsigned).
let validity_lower_slot = tip_slot; let validity_lower_slot = tip_slot;
let validity_lower_ms = slot_to_posix_ms(cfg.network, validity_lower_slot)?; let validity_lower_ms = slot_to_posix_ms(cfg.network, validity_lower_slot)?;
let validity_upper_slot = tip_slot + validity_window_seconds.unwrap_or(1800); let validity_upper_slot = tip_slot + validity_window_seconds.unwrap_or(1800);
@ -4493,16 +4407,15 @@ pub struct EscrowOpenUnsignedArgs {
pub open_deadline_ms: i64, pub open_deadline_ms: i64,
/// Veto-window length after Agree, in ms. /// Veto-window length after Agree, in ms.
pub lock_period_ms: i64, pub lock_period_ms: i64,
/// Optional pkh of the initial contributor (must equal party_a or party_b). /// pkh of the initial contributor (must equal party_a or party_b).
/// **HIGH-2 fix 2026-05-09:** The builder now REJECTS `None` here — every /// Required — every escrow needs at least one contributor at open
/// escrow must have at least one initial contributor at open time. /// time. `None` returns an error. The field stays `Option<String>`
/// Sending `null` returns an error. Field stays `Option<String>` for /// for schema stability.
/// schema-stability.
#[serde(default)] #[serde(default)]
pub initial_contributor_pkh_hex: Option<String>, pub initial_contributor_pkh_hex: Option<String>,
/// Lovelace to lock at the escrow output. **MED-5 fix:** must clear /// Lovelace to lock at the escrow output. Must clear 2_000_000
/// 2_000_000 (≥2 ADA) — Conway-era inline-datum + script-address /// (≥2 ADA) — Conway-era inline-datum + script-address outputs
/// outputs need ~1.4-1.7 ADA min-utxo, with headroom. /// need ~1.4-1.7 ADA min-utxo, with headroom.
pub initial_lovelace: u64, pub initial_lovelace: u64,
} }
@ -4830,8 +4743,7 @@ mod sandbox_tests {
use std::fs; use std::fs;
use std::io::Write; use std::io::Write;
/// CRIT-2 audit fix (2026-05-12): files inside the sandbox root resolve /// Files inside the sandbox root resolve cleanly via canonicalization.
/// cleanly via canonicalization.
#[test] #[test]
fn path_inside_sandbox_is_accepted() { fn path_inside_sandbox_is_accepted() {
let root = tempfile::tempdir().expect("tempdir"); let root = tempfile::tempdir().expect("tempdir");
@ -4880,12 +4792,11 @@ mod sandbox_tests {
); );
} }
/// Adversarial-review fix: hardlinked files inside the sandbox /// Hardlinked files inside the sandbox must be refused.
/// must be refused. `canonicalize` resolves symlinks but NOT /// `canonicalize` resolves symlinks but not hardlinks (a hardlink
/// hardlinks — a hardlink IS the file (same inode, different /// IS the file — same inode), so without this guard an attacker
/// directory entry). Without this, an attacker with daemon-uid /// with daemon-uid write access could `link()` the encrypted key
/// write access could `link()` the encrypted key blob's inode /// blob's inode into the sandbox and exfiltrate the bytes.
/// into the sandbox and exfiltrate the bytes.
#[cfg(unix)] #[cfg(unix)]
#[test] #[test]
fn hardlinked_file_inside_sandbox_is_rejected() { fn hardlinked_file_inside_sandbox_is_rejected() {
@ -4996,9 +4907,8 @@ mod cap_tests {
(cbor, wallet_addr) (cbor, wallet_addr)
} }
/// CRIT-1: a tx whose non-self output sits below the cap returns 0 /// A tx whose only outputs are self-sends contributes 0 to the
/// total when the destination IS self (change-only); above cap the /// non-self total — self-moves don't count against the cap.
/// sum reflects the actual outbound lovelace.
#[test] #[test]
fn sum_non_self_excludes_self_outputs() { fn sum_non_self_excludes_self_outputs() {
// Send to the wallet's OWN address — every output is "self"; // Send to the wallet's OWN address — every output is "self";
@ -5016,9 +4926,8 @@ mod cap_tests {
); );
} }
/// CRIT-1: a tx sending lovelace to an UNRELATED address has its /// Outbound lovelace to an unrelated address counts toward the
/// destination output reflected in the non-self sum, so the /// non-self sum so the cap chokepoint can refuse to sign/submit.
/// enforce_value_cap chokepoint can refuse to sign/submit it.
#[test] #[test]
fn sum_non_self_counts_outbound_outputs() { fn sum_non_self_counts_outbound_outputs() {
// Send to a different derivation (account=0, index=1) of the // Send to a different derivation (account=0, index=1) of the
@ -5038,22 +4947,11 @@ mod cap_tests {
); );
} }
/// Adversarial-review fix: `.sum::<u64>()` wraps on overflow in /// The cap check must never silently roll over a u64 —
/// release builds. The cap check must NEVER silently roll over a /// `.sum::<u64>()` wraps on overflow in release builds. We use
/// u64 — a prompt-injection could craft CBOR with outputs summing /// `checked_add` and return Err instead. Tested against
/// past u64::MAX, wrap to a small value, pass the check. Now uses /// synthetic `OutputSummary` values because Cardano min-utxo +
/// `checked_add` and returns Err on overflow. We test this via the /// max-supply make a real overflow CBOR impossible to build.
/// free function with hand-constructed `OutputSummary` mock data
/// rather than building a real CBOR (Cardano min-utxo + max-supply
/// constraints make a real overflow tx impossible to build).
///
/// This test exercises `OutputSummary` from `aldabra-core::inspect`
/// to confirm the type's `lovelace: u64` field is what we expect,
/// and that the cap check refuses to silently roll over. A future
/// refactor could expose a `compute_total` helper taking a slice
/// of OutputSummary so we can test overflow in isolation; for now
/// the test relies on documenting the invariant via a regression
/// case in the free function below.
#[test] #[test]
fn sum_non_self_handles_overflow_safely() { fn sum_non_self_handles_overflow_safely() {
// Build two synthetic OutputSummary mocks via the public type. // Build two synthetic OutputSummary mocks via the public type.