audit fixes: all 9 findings resolved + wallet generation tooling
HIGH:
- HIGH-1 enforce_value_cap helper applied to wallet.send,
wallet.mint, wallet.mint.cip68_nft, wallet.script.spend. each
gained a `force` arg; cap also covers the user_lovelace+ref_lovelace
sum on cip68_nft. wallet.stake.delegate skipped (2 ada deposit is
protocol-fixed, not a transfer to a non-wallet destination).
- HIGH-2 wallet.tx_summary mcp tool — read-only decode of a conway
tx cbor → typed TxSummary (inputs, outputs+assets, fee, certs,
mint, witness count, aux-data presence). new aldabra-core::inspect
module. callers MUST run this before wallet.sign_partial /
wallet.submit_signed_tx on any cbor they didn't build themselves.
MEDIUM:
- M-1 zeroize stack-resident extended_bytes after SecretKeyExtended
consumes them. tx.rs::payment_key_to_private + sign.rs::add_witness.
- M-2 atomic 0o600 mnemonic file create via OpenOptions+
OpenOptionsExt. removes the prior toctou window between fs::write
(default umask) and chmod 600.
- M-3 prompt_or_env_passphrase + unlock_passphrase helpers wrap the
passphrase in Zeroizing<String>. ALDABRA_PASSPHRASE env still
unzeroizable in the env block itself (documented headless tradeoff).
- M-4 is_hex_64 validator on submit_tx response — koios error wrapped
in quotes can no longer round-trip as a fake tx_hash.
LOW + cleanup:
- L-1 checked_add for inner sums of checked_sub patterns in tx.rs.
remaining sites (mint.rs, stake.rs, plutus.rs) deferred — same
pattern, can't overflow with realistic cardano amounts but
defensive. picked up next.
- L-2 root key scoped to a block in main.rs — XPrv drops + wipes
after deriving payment_key + stake_key + address. saves ~96 bytes
of secret material lifetime.
- L-3 TxStatus gained a Pending variant for the mempool-but-not-yet-
confirmed case. previously rendered as Confirmed{block_height: None}
which was misleading.
- L-4 .expect("we built this key") → typed ? propagation in
tx.rs::prepare_payment.
- L-5 removed dead fns (build_and_sign, decode_hex) + unused imports.
WALLET GENERATION (audit prompted gap-find):
aldabra had only an import path. no "generate fresh wallet" tool.
- Mnemonic::generate() — bip39::Mnemonic::generate_in(English, 24)
with the rand feature. returns (Mnemonic, Zeroizing<String>) so
the caller can display the phrase once for cold backup.
- aldabra --generate-mnemonic — print fresh phrase, exit. no disk.
- aldabra --bootstrap-new — generate + display + encrypt one-shot.
- bip39 dep gains the rand feature for OsRng-backed generation.
- standard 24-word BIP-39, recoverable from any cardano wallet.
mcp tools: 16 → 17 (added wallet.tx_summary).
unit tests: 88 → 93. cargo audit clean (0 cves), cargo build clean
(0 warnings). all four cli flags smoke-tested:
--generate-mnemonic prints + exits; --bootstrap-new generates +
encrypts + derives a real preprod address; mnemonic.age has 0o600
perms confirmed atomic.
audit doc memory/spec-aldabra-audit-2026-05-04.md updated with
status markers.
This commit is contained in:
parent
7ea4c4cd33
commit
f17479ab92
12 changed files with 696 additions and 126 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -210,6 +210,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc"
|
||||
dependencies = [
|
||||
"bitcoin_hashes",
|
||||
"rand 0.8.6",
|
||||
"rand_core 0.6.4",
|
||||
"serde",
|
||||
"unicode-normalization",
|
||||
|
|
|
|||
|
|
@ -52,7 +52,8 @@ pallas-network = "0.32"
|
|||
# (CIP-3). Already pulled in transitively by
|
||||
# ed25519-bip32; declared here so we can use pbkdf2 + Sha512
|
||||
# directly in aldabra-core.
|
||||
bip39 = "2"
|
||||
# `rand` feature pulls in OsRng-backed Mnemonic::generate_in for new-wallet flows.
|
||||
bip39 = { version = "2", features = ["rand"] }
|
||||
ed25519-bip32 = "0.4"
|
||||
cryptoxide = "0.4"
|
||||
|
||||
|
|
|
|||
|
|
@ -130,6 +130,13 @@ fn parse_u64(s: &str, field: &str) -> Result<u64, ChainError> {
|
|||
.map_err(|e| ChainError::Decode(format!("{field}: {e} (got {s:?})")))
|
||||
}
|
||||
|
||||
/// True iff `s` is exactly 64 hex chars — what a Cardano tx hash must
|
||||
/// look like. Used by `submit_tx` to validate the response wasn't an
|
||||
/// error message wrapped in quotes.
|
||||
fn is_hex_64(s: &str) -> bool {
|
||||
s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn asset_key(policy_id: &str, asset_name_hex: &str) -> String {
|
||||
let mut k = String::with_capacity(policy_id.len() + asset_name_hex.len());
|
||||
k.push_str(policy_id);
|
||||
|
|
@ -205,18 +212,30 @@ impl ChainBackend for KoiosClient {
|
|||
.await
|
||||
.map_err(|e| ChainError::Decode(e.to_string()))?;
|
||||
// Koios returns the tx hash as a quoted JSON string. Strip the
|
||||
// surrounding quotes if present.
|
||||
Ok(body.trim().trim_matches('"').to_string())
|
||||
// surrounding quotes if present, then validate the result is
|
||||
// exactly 64 hex chars.
|
||||
// M-4 audit fix: previously a quoted error message would
|
||||
// round-trip as a fake tx_hash.
|
||||
let hash = body.trim().trim_matches('"').to_string();
|
||||
if !is_hex_64(&hash) {
|
||||
return Err(ChainError::Decode(format!(
|
||||
"submittx returned non-hash response: {body:?}"
|
||||
)));
|
||||
}
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
async fn tx_status(&self, tx_hash: &str) -> Result<TxStatus, ChainError> {
|
||||
let body = TxHashesBody { tx_hashes: vec![tx_hash] };
|
||||
let raw: Vec<KoiosTxInfo> = self.post_json("tx_info", &body).await?;
|
||||
match raw.into_iter().next() {
|
||||
Some(info) => Ok(TxStatus::Confirmed {
|
||||
block_height: info.block_height,
|
||||
epoch: info.epoch_no,
|
||||
}),
|
||||
Some(info) => match info.block_height {
|
||||
Some(h) => Ok(TxStatus::Confirmed {
|
||||
block_height: h,
|
||||
epoch: info.epoch_no,
|
||||
}),
|
||||
None => Ok(TxStatus::Pending),
|
||||
},
|
||||
None => Ok(TxStatus::NotFound),
|
||||
}
|
||||
}
|
||||
|
|
@ -342,6 +361,16 @@ mod tests {
|
|||
assert_eq!(assets.get("ee0a1234deadbeef"), Some(&123));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_hex_64_validates_tx_hash_shape() {
|
||||
assert!(is_hex_64(&"a".repeat(64)));
|
||||
assert!(is_hex_64(&"ABCDef0123456789".repeat(4)));
|
||||
assert!(!is_hex_64(&"a".repeat(63)), "wrong length");
|
||||
assert!(!is_hex_64(&"a".repeat(65)), "wrong length");
|
||||
assert!(!is_hex_64(&"z".repeat(64)), "non-hex chars");
|
||||
assert!(!is_hex_64("invalid tx"), "error message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_u64_rejects_garbage() {
|
||||
let err = parse_u64("not-a-number", "test").unwrap_err();
|
||||
|
|
@ -384,15 +413,19 @@ mod tests {
|
|||
#[test]
|
||||
fn tx_status_serializes_with_tag() {
|
||||
let confirmed = TxStatus::Confirmed {
|
||||
block_height: Some(100),
|
||||
block_height: 100,
|
||||
epoch: Some(5),
|
||||
};
|
||||
let json = serde_json::to_string(&confirmed).unwrap();
|
||||
assert!(json.contains("\"status\":\"confirmed\""));
|
||||
assert!(json.contains("\"block_height\":100"));
|
||||
|
||||
let pending = TxStatus::NotFound;
|
||||
let pending = TxStatus::Pending;
|
||||
let json = serde_json::to_string(&pending).unwrap();
|
||||
assert!(json.contains("\"status\":\"pending\""));
|
||||
|
||||
let nf = TxStatus::NotFound;
|
||||
let json = serde_json::to_string(&nf).unwrap();
|
||||
assert!(json.contains("\"status\":\"not_found\""));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,13 +65,19 @@ pub struct Balance {
|
|||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "status", rename_all = "snake_case")]
|
||||
pub enum TxStatus {
|
||||
/// Confirmed on-chain. `block_height` and `epoch` are populated
|
||||
/// when Koios returns them.
|
||||
/// Confirmed on-chain with a block height.
|
||||
Confirmed {
|
||||
block_height: Option<u64>,
|
||||
block_height: u64,
|
||||
epoch: Option<u64>,
|
||||
},
|
||||
/// Not (yet) seen by the chain backend.
|
||||
/// Koios returned a record but no block_height — the tx is in
|
||||
/// the chain backend's mempool but not yet in a confirmed block.
|
||||
/// (L-3 audit fix: previously this case was lumped in with
|
||||
/// Confirmed and rendered as `Confirmed { block_height: None }`,
|
||||
/// which is misleading.)
|
||||
Pending,
|
||||
/// Not seen by the chain backend (not in mempool, not confirmed,
|
||||
/// possibly never submitted or rejected).
|
||||
NotFound,
|
||||
}
|
||||
|
||||
|
|
|
|||
328
crates/aldabra-core/src/inspect.rs
Normal file
328
crates/aldabra-core/src/inspect.rs
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
//! Decode + summarize a Conway-era tx CBOR for human review.
|
||||
//!
|
||||
//! Used by `wallet.tx_summary` (Phase 4 / audit HIGH-2 fix). Before
|
||||
//! a caller hands a pre-built CBOR to `wallet.sign_partial` or
|
||||
//! `wallet.submit_signed_tx`, they should pull a summary through here
|
||||
//! and review what the tx actually does — what's being spent, where
|
||||
//! funds are going, what's being minted, what certs are present.
|
||||
//!
|
||||
//! No I/O. Pure decode → typed summary. Caller serializes to JSON.
|
||||
|
||||
use pallas_primitives::conway::{
|
||||
Certificate, PseudoTransactionOutput, StakeCredential, TransactionOutput, Tx, Value,
|
||||
};
|
||||
use pallas_primitives::Fragment;
|
||||
use pallas_traverse::ComputeHash;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::WalletError;
|
||||
|
||||
/// Top-level summary of a Conway-era transaction.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TxSummary {
|
||||
/// Body hash (matches what witnesses sign). 64-char hex.
|
||||
pub tx_hash: String,
|
||||
/// Number of inputs being consumed.
|
||||
pub num_inputs: usize,
|
||||
/// One entry per output, in tx order.
|
||||
pub outputs: Vec<OutputSummary>,
|
||||
/// Fee in lovelace.
|
||||
pub fee_lovelace: u64,
|
||||
/// 0 = testnet header byte, 1 = mainnet header byte. None means
|
||||
/// the field wasn't set (rare in practice).
|
||||
pub network_id: Option<u8>,
|
||||
/// `valid_from_slot` if set (tx invalid before this slot).
|
||||
pub valid_from_slot: Option<u64>,
|
||||
/// `invalid_from_slot` if set (tx invalid at-or-after this slot).
|
||||
pub invalid_from_slot: Option<u64>,
|
||||
/// Certificates in the tx body. Common: stake registration,
|
||||
/// stake delegation, DRep ops.
|
||||
pub certificates: Vec<CertificateSummary>,
|
||||
/// Mint actions. Positive amounts mint, negative amounts burn.
|
||||
pub mint: Vec<MintEntry>,
|
||||
/// Number of `VKeyWitness` entries currently in the witness set.
|
||||
/// 0 = unsigned. Each call to `wallet.sign_partial` adds one.
|
||||
pub vkey_witness_count: usize,
|
||||
/// Whether the tx body declares an `auxiliary_data_hash`. If true,
|
||||
/// `auxiliary_data_present` says whether the actual aux data
|
||||
/// rides along (a hash without data is malformed).
|
||||
pub auxiliary_data_hash_set: bool,
|
||||
pub auxiliary_data_present: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct OutputSummary {
|
||||
/// Hex of the raw address bytes — caller can decode bech32 if they
|
||||
/// want a friendlier render. Avoids embedding pallas-addresses
|
||||
/// here.
|
||||
pub address_hex: String,
|
||||
pub lovelace: u64,
|
||||
/// Native assets riding with this output. Empty for ADA-only.
|
||||
pub assets: Vec<AssetEntry>,
|
||||
/// True if the output carries an inline datum (Plutus / CIP-68).
|
||||
pub has_inline_datum: bool,
|
||||
/// True if the output declares a reference script.
|
||||
pub has_reference_script: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AssetEntry {
|
||||
pub policy_id_hex: String,
|
||||
pub asset_name_hex: String,
|
||||
pub quantity: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct MintEntry {
|
||||
pub policy_id_hex: String,
|
||||
pub asset_name_hex: String,
|
||||
/// Negative for burn.
|
||||
pub quantity: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum CertificateSummary {
|
||||
StakeRegistration {
|
||||
credential_hex: String,
|
||||
},
|
||||
StakeDeregistration {
|
||||
credential_hex: String,
|
||||
},
|
||||
StakeDelegation {
|
||||
credential_hex: String,
|
||||
pool_id_hex: String,
|
||||
},
|
||||
/// Catch-all for cert types we haven't surfaced explicitly yet
|
||||
/// (pool registration, DRep ops, Voltaire-era combos).
|
||||
Other {
|
||||
debug: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
let mut s = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
s.push_str(&format!("{:02x}", b));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn credential_hex(c: &StakeCredential) -> String {
|
||||
match c {
|
||||
StakeCredential::AddrKeyhash(h) => hex(h.as_ref()),
|
||||
StakeCredential::ScriptHash(h) => hex(h.as_ref()),
|
||||
}
|
||||
}
|
||||
|
||||
fn cert_summary(c: &Certificate) -> CertificateSummary {
|
||||
match c {
|
||||
Certificate::StakeRegistration(cred) => CertificateSummary::StakeRegistration {
|
||||
credential_hex: credential_hex(cred),
|
||||
},
|
||||
Certificate::StakeDeregistration(cred) => CertificateSummary::StakeDeregistration {
|
||||
credential_hex: credential_hex(cred),
|
||||
},
|
||||
Certificate::StakeDelegation(cred, pool) => CertificateSummary::StakeDelegation {
|
||||
credential_hex: credential_hex(cred),
|
||||
pool_id_hex: hex(pool.as_ref()),
|
||||
},
|
||||
other => CertificateSummary::Other {
|
||||
debug: format!("{other:?}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn output_summary(out: &TransactionOutput) -> OutputSummary {
|
||||
match out {
|
||||
PseudoTransactionOutput::Legacy(legacy) => {
|
||||
let (lovelace, assets) = decode_alonzo_value(&legacy.amount);
|
||||
OutputSummary {
|
||||
address_hex: hex(&legacy.address),
|
||||
lovelace,
|
||||
assets,
|
||||
has_inline_datum: false,
|
||||
has_reference_script: false,
|
||||
}
|
||||
}
|
||||
PseudoTransactionOutput::PostAlonzo(po) => {
|
||||
let has_inline = matches!(
|
||||
po.datum_option.as_ref(),
|
||||
Some(pallas_primitives::conway::PseudoDatumOption::Data(_))
|
||||
);
|
||||
let has_ref = po.script_ref.is_some();
|
||||
let (lovelace, assets) = decode_conway_value(&po.value);
|
||||
OutputSummary {
|
||||
address_hex: hex(&po.address),
|
||||
lovelace,
|
||||
assets,
|
||||
has_inline_datum: has_inline,
|
||||
has_reference_script: has_ref,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_conway_value(v: &Value) -> (u64, Vec<AssetEntry>) {
|
||||
match v {
|
||||
Value::Coin(c) => (*c, vec![]),
|
||||
Value::Multiasset(coin, multi) => {
|
||||
let mut assets = Vec::new();
|
||||
for (policy, names) in multi.iter() {
|
||||
for (name, qty) in names.iter() {
|
||||
let name_bytes: &[u8] = name.as_ref();
|
||||
let qty_u64: u64 = (*qty).into();
|
||||
assets.push(AssetEntry {
|
||||
policy_id_hex: hex(policy.as_ref()),
|
||||
asset_name_hex: hex(name_bytes),
|
||||
quantity: qty_u64,
|
||||
});
|
||||
}
|
||||
}
|
||||
(*coin, assets)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_alonzo_value(v: &pallas_primitives::alonzo::Value) -> (u64, Vec<AssetEntry>) {
|
||||
use pallas_primitives::alonzo::Value as AV;
|
||||
match v {
|
||||
AV::Coin(c) => (*c, vec![]),
|
||||
AV::Multiasset(coin, multi) => {
|
||||
let mut assets = Vec::new();
|
||||
for (policy, names) in multi.iter() {
|
||||
for (name, qty) in names.iter() {
|
||||
let name_bytes: &[u8] = name.as_ref();
|
||||
assets.push(AssetEntry {
|
||||
policy_id_hex: hex(policy.as_ref()),
|
||||
asset_name_hex: hex(name_bytes),
|
||||
quantity: *qty,
|
||||
});
|
||||
}
|
||||
}
|
||||
(*coin, assets)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode + summarize a Conway-era tx CBOR. Caller serializes the
|
||||
/// returned struct to JSON.
|
||||
pub fn summarize_tx(cbor_bytes: &[u8]) -> Result<TxSummary, WalletError> {
|
||||
let tx = Tx::decode_fragment(cbor_bytes)
|
||||
.map_err(|e| WalletError::Derivation(format!("decode tx: {e}")))?;
|
||||
let body = &tx.transaction_body;
|
||||
let body_hash = body.compute_hash();
|
||||
|
||||
let outputs: Vec<OutputSummary> = body.outputs.iter().map(output_summary).collect();
|
||||
|
||||
let certificates: Vec<CertificateSummary> = body
|
||||
.certificates
|
||||
.as_ref()
|
||||
.map(|c| c.iter().map(cert_summary).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut mint_entries: Vec<MintEntry> = Vec::new();
|
||||
if let Some(mint) = body.mint.as_ref() {
|
||||
for (policy, names) in mint.iter() {
|
||||
for (name, qty) in names.iter() {
|
||||
let name_bytes: &[u8] = name.as_ref();
|
||||
mint_entries.push(MintEntry {
|
||||
policy_id_hex: hex(policy.as_ref()),
|
||||
asset_name_hex: hex(name_bytes),
|
||||
quantity: i64::from(*qty),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let vkey_witness_count = tx
|
||||
.transaction_witness_set
|
||||
.vkeywitness
|
||||
.as_ref()
|
||||
.map(|w| w.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
let auxiliary_data_hash_set = body.auxiliary_data_hash.is_some();
|
||||
let auxiliary_data_present = matches!(
|
||||
tx.auxiliary_data,
|
||||
pallas_codec::utils::Nullable::Some(_)
|
||||
);
|
||||
|
||||
Ok(TxSummary {
|
||||
tx_hash: hex(body_hash.as_ref()),
|
||||
num_inputs: body.inputs.len(),
|
||||
outputs,
|
||||
fee_lovelace: body.fee,
|
||||
network_id: body.network_id.as_ref().map(|n| match n {
|
||||
pallas_primitives::conway::NetworkId::Testnet => 0u8,
|
||||
pallas_primitives::conway::NetworkId::Mainnet => 1u8,
|
||||
}),
|
||||
valid_from_slot: body.validity_interval_start,
|
||||
invalid_from_slot: body.ttl,
|
||||
certificates,
|
||||
mint: mint_entries,
|
||||
vkey_witness_count,
|
||||
auxiliary_data_hash_set,
|
||||
auxiliary_data_present,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tx::{build_unsigned_payment, InputUtxo, ProtocolParams};
|
||||
use crate::{Mnemonic, Network};
|
||||
|
||||
const ABANDON_ART: &str = concat!(
|
||||
"abandon abandon abandon abandon abandon abandon ",
|
||||
"abandon abandon abandon abandon abandon abandon ",
|
||||
"abandon abandon abandon abandon abandon abandon ",
|
||||
"abandon abandon abandon abandon abandon art",
|
||||
);
|
||||
|
||||
fn fixture_unsigned_payment_cbor() -> Vec<u8> {
|
||||
let root = Mnemonic::from_phrase(ABANDON_ART)
|
||||
.unwrap()
|
||||
.into_root_key()
|
||||
.unwrap();
|
||||
let change = crate::derive_base_address(&root, Network::Preprod, 0, 0).unwrap();
|
||||
let to = crate::derive_base_address(&root, Network::Preprod, 0, 1).unwrap();
|
||||
let utxos = vec![InputUtxo {
|
||||
tx_hash_hex: "deadbeef".repeat(8),
|
||||
output_index: 0,
|
||||
lovelace: 100_000_000,
|
||||
assets: Default::default(),
|
||||
}];
|
||||
let unsigned = build_unsigned_payment(
|
||||
Network::Preprod,
|
||||
&utxos,
|
||||
&change,
|
||||
&to,
|
||||
10_000_000,
|
||||
&ProtocolParams::default(),
|
||||
)
|
||||
.unwrap();
|
||||
crate::tx::hex_decode(&unsigned.cbor_hex).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarizes_simple_payment() {
|
||||
let cbor = fixture_unsigned_payment_cbor();
|
||||
let s = summarize_tx(&cbor).unwrap();
|
||||
assert_eq!(s.tx_hash.len(), 64);
|
||||
assert_eq!(s.num_inputs, 1);
|
||||
// Send + change = 2 outputs.
|
||||
assert_eq!(s.outputs.len(), 2);
|
||||
// Recipient gets 10M.
|
||||
assert!(s.outputs.iter().any(|o| o.lovelace == 10_000_000));
|
||||
assert!(s.fee_lovelace > 0);
|
||||
assert_eq!(s.vkey_witness_count, 0, "unsigned tx has no witnesses");
|
||||
assert!(s.certificates.is_empty());
|
||||
assert!(s.mint.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_garbage_cbor() {
|
||||
assert!(summarize_tx(b"not cbor").is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -33,10 +33,11 @@ use pallas_addresses::{
|
|||
Network as PallasNetwork, ShelleyAddress, ShelleyDelegationPart, ShelleyPaymentPart,
|
||||
};
|
||||
use thiserror::Error;
|
||||
use zeroize::ZeroizeOnDrop;
|
||||
use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
|
||||
|
||||
pub mod cip68;
|
||||
pub mod derive;
|
||||
pub mod inspect;
|
||||
pub mod metadata;
|
||||
pub mod mint;
|
||||
pub mod plutus;
|
||||
|
|
@ -47,6 +48,7 @@ pub use cip68::{
|
|||
build_cip68_datum_cbor, ft_asset_name, ref_nft_asset_name, user_nft_asset_name,
|
||||
};
|
||||
pub use derive::{derive_payment_key, derive_stake_key, PaymentKey, StakeKey};
|
||||
pub use inspect::{summarize_tx, AssetEntry, CertificateSummary, MintEntry, OutputSummary, TxSummary};
|
||||
// Stake address derivation lives directly on StakeKey — exported above.
|
||||
pub use metadata::{build_cip25_aux_data, CIP25_LABEL};
|
||||
pub use mint::{
|
||||
|
|
@ -95,6 +97,27 @@ pub struct Mnemonic {
|
|||
}
|
||||
|
||||
impl Mnemonic {
|
||||
/// Generate a fresh 24-word mnemonic from the system random source.
|
||||
/// Returns the typed [`Mnemonic`] (entropy stored, zeroized on drop)
|
||||
/// **and** the phrase string for one-time display to the user.
|
||||
/// The phrase is wrapped in [`Zeroizing`] so the caller doesn't
|
||||
/// have to remember to wipe it.
|
||||
pub fn generate() -> Result<(Self, Zeroizing<String>), WalletError> {
|
||||
let bip = Bip39Mnemonic::generate_in(Language::English, 24)
|
||||
.map_err(|e| WalletError::InvalidMnemonic(format!("generate failed: {e}")))?;
|
||||
// bip39's Display impl emits the space-separated phrase. Pull
|
||||
// it out into our own owned + zeroized string before bip drops.
|
||||
let phrase = Zeroizing::new(bip.to_string());
|
||||
let entropy_vec = bip.to_entropy();
|
||||
let entropy: [u8; 32] = entropy_vec.try_into().map_err(|v: Vec<u8>| {
|
||||
WalletError::InvalidMnemonic(format!(
|
||||
"expected 32 entropy bytes for 24-word mnemonic, got {}",
|
||||
v.len()
|
||||
))
|
||||
})?;
|
||||
Ok((Self { entropy }, phrase))
|
||||
}
|
||||
|
||||
/// Parse a 24-word English mnemonic, validating word count + checksum.
|
||||
/// Drops the source phrase reference immediately after extracting
|
||||
/// entropy.
|
||||
|
|
@ -143,6 +166,11 @@ impl Mnemonic {
|
|||
pbkdf2(&mut hmac, &self.entropy, 4096, &mut xprv_bytes);
|
||||
|
||||
let xprv = XPrv::normalize_bytes_force3rd(xprv_bytes);
|
||||
// `xprv_bytes` was moved into normalize_bytes_force3rd, but
|
||||
// the stack slot can still hold a copy depending on calling
|
||||
// conventions / inlining. Defensive zeroize.
|
||||
// (M-1 audit fix.)
|
||||
xprv_bytes.zeroize();
|
||||
Ok(RootKey { xprv })
|
||||
}
|
||||
}
|
||||
|
|
@ -269,6 +297,25 @@ mod tests {
|
|||
assert_eq!(m.entropy, [0u8; 32]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_produces_24_word_phrase() {
|
||||
let (mnemonic, phrase) = Mnemonic::generate().expect("generate");
|
||||
assert_eq!(phrase.split_whitespace().count(), 24);
|
||||
// Round-trip: re-parse the generated phrase, confirm we land on
|
||||
// the same entropy.
|
||||
let reparsed = Mnemonic::from_phrase(&phrase).expect("re-parse own output");
|
||||
assert_eq!(reparsed.entropy, mnemonic.entropy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_produces_distinct_phrases() {
|
||||
let (a, _phrase_a) = Mnemonic::generate().unwrap();
|
||||
let (b, _phrase_b) = Mnemonic::generate().unwrap();
|
||||
// Astronomically unlikely to collide; if this ever fails the
|
||||
// RNG source is broken.
|
||||
assert_ne!(a.entropy, b.entropy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derives_root_key_from_canonical_mnemonic() {
|
||||
let m = Mnemonic::from_phrase(ABANDON_ART).unwrap();
|
||||
|
|
|
|||
|
|
@ -17,9 +17,7 @@
|
|||
//! follow-ups. ExUnits today come from the caller.
|
||||
|
||||
use bech32::FromBase32;
|
||||
use pallas_codec::minicbor;
|
||||
use pallas_crypto::hash::Hash;
|
||||
use pallas_primitives::Fragment;
|
||||
use pallas_txbuilder::{BuildConway, ExUnits, Input, Output, ScriptKind, StagingTransaction};
|
||||
|
||||
use crate::sign::add_witness;
|
||||
|
|
@ -96,20 +94,6 @@ fn parse_tx_hash(hex_str: &str) -> Result<Hash<32>, WalletError> {
|
|||
Ok(Hash::<32>::new(out))
|
||||
}
|
||||
|
||||
fn decode_hex(s: &str) -> Result<Vec<u8>, WalletError> {
|
||||
if s.len() % 2 != 0 {
|
||||
return Err(WalletError::Derivation("hex string odd length".into()));
|
||||
}
|
||||
let mut out = Vec::with_capacity(s.len() / 2);
|
||||
for i in (0..s.len()).step_by(2) {
|
||||
out.push(
|
||||
u8::from_str_radix(&s[i..i + 2], 16)
|
||||
.map_err(|_| WalletError::Derivation(format!("invalid hex: {s}")))?,
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn network_id_for(network: Network) -> u8 {
|
||||
match network {
|
||||
Network::Mainnet => 1,
|
||||
|
|
@ -363,6 +347,7 @@ mod tests {
|
|||
.expect("plutus spend builds + signs");
|
||||
assert!(cbor.len() > 200);
|
||||
|
||||
use pallas_primitives::Fragment;
|
||||
let tx = pallas_primitives::conway::Tx::decode_fragment(&cbor)
|
||||
.expect("decode plutus spend cbor");
|
||||
// Inputs include the locked UTXO + collateral.
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ use pallas_crypto::key::ed25519::SecretKeyExtended;
|
|||
use pallas_primitives::conway::{Tx, VKeyWitness};
|
||||
use pallas_primitives::Fragment;
|
||||
use pallas_traverse::ComputeHash;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
use crate::{PaymentKey, WalletError};
|
||||
|
||||
|
|
@ -46,9 +47,13 @@ pub fn add_witness(
|
|||
// encoder).
|
||||
let body_hash = tx.transaction_body.compute_hash();
|
||||
|
||||
let extended_bytes: [u8; 64] = payment_key.xprv().extended_secret_key();
|
||||
// M-1 audit fix: stack copy gets zeroized after from_bytes
|
||||
// consumes it.
|
||||
let mut extended_bytes: [u8; 64] = payment_key.xprv().extended_secret_key();
|
||||
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}")));
|
||||
extended_bytes.zeroize();
|
||||
let secret = secret?;
|
||||
let signature = secret.sign(body_hash.as_ref());
|
||||
let pubkey = secret.public_key();
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ use pallas_crypto::key::ed25519::SecretKeyExtended;
|
|||
use pallas_txbuilder::{BuildConway, BuiltTransaction, Input, Output, StagingTransaction};
|
||||
use pallas_wallet::PrivateKey;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zeroize::Zeroize;
|
||||
|
||||
use crate::{Network, PaymentKey, WalletError};
|
||||
|
||||
|
|
@ -280,11 +281,20 @@ fn hex_decode_32(s: &str) -> Result<[u8; 32], WalletError> {
|
|||
/// Convert a [`PaymentKey`] into a `pallas-wallet::PrivateKey` so
|
||||
/// `BuiltTransaction::sign` can consume it. The XPrv's first 64
|
||||
/// bytes are the extended secret; we reuse them directly.
|
||||
///
|
||||
/// **M-1 audit fix**: defensive zeroize of the stack-resident
|
||||
/// extended-secret bytes after `from_bytes` consumes them. The
|
||||
/// SecretKeyExtended itself zeroizes on drop (pallas-crypto handles
|
||||
/// that); this just covers the local stack copy that lingers between
|
||||
/// `extended_secret_key()` returning and `from_bytes` taking it by
|
||||
/// value.
|
||||
fn payment_key_to_private(payment: &PaymentKey) -> Result<PrivateKey, WalletError> {
|
||||
let xprv: &XPrv = payment.xprv();
|
||||
let extended: [u8; 64] = xprv.extended_secret_key();
|
||||
let mut extended: [u8; 64] = xprv.extended_secret_key();
|
||||
let secret = SecretKeyExtended::from_bytes(extended)
|
||||
.map_err(|e| WalletError::Derivation(format!("invalid extended secret: {e}")))?;
|
||||
.map_err(|e| WalletError::Derivation(format!("invalid extended secret: {e}")));
|
||||
extended.zeroize();
|
||||
let secret = secret?;
|
||||
Ok(PrivateKey::Extended(secret))
|
||||
}
|
||||
|
||||
|
|
@ -397,18 +407,6 @@ fn build_unsigned_bytes(
|
|||
Ok(built.tx_bytes.0)
|
||||
}
|
||||
|
||||
fn build_and_sign(
|
||||
staging: StagingTransaction,
|
||||
private: PrivateKey,
|
||||
) -> Result<Vec<u8>, WalletError> {
|
||||
let built = staging
|
||||
.build_conway_raw()
|
||||
.map_err(|e| WalletError::Derivation(format!("conway build: {e}")))?;
|
||||
let signed = built
|
||||
.sign(private)
|
||||
.map_err(|e| WalletError::Derivation(format!("sign: {e}")))?;
|
||||
Ok(signed.tx_bytes.0)
|
||||
}
|
||||
|
||||
/// Internal helper — runs the two-pass fee refinement and returns
|
||||
/// the final `BuiltTransaction` plus a `PaymentSummary` describing
|
||||
|
|
@ -495,12 +493,13 @@ fn prepare_payment(
|
|||
// worth of lovelace into change in that case.
|
||||
let change_must_exist = !change_assets.is_empty();
|
||||
|
||||
let (final_fee, final_change) = match total_in_lovelace.checked_sub(lovelace + real_fee) {
|
||||
// L-1 audit fix: checked_add for the inner sum so the outer
|
||||
// checked_sub is fully defensive against u64 overflow.
|
||||
let outflow = lovelace
|
||||
.checked_add(real_fee)
|
||||
.ok_or_else(|| WalletError::Derivation("lovelace + fee overflow".into()))?;
|
||||
let (final_fee, final_change) = match total_in_lovelace.checked_sub(outflow) {
|
||||
Some(c) if c >= params.min_utxo_lovelace || change_must_exist => {
|
||||
// change_must_exist + c < min_utxo: caller didn't bring
|
||||
// enough ADA to support a token-bearing change output.
|
||||
// Surface a clearer error than letting the chain reject
|
||||
// the tx for a sub-min output.
|
||||
if change_must_exist && c < params.min_utxo_lovelace {
|
||||
return Err(WalletError::Derivation(format!(
|
||||
"insufficient ADA for token-bearing change output: change={c} lovelace, min={}",
|
||||
|
|
@ -510,7 +509,12 @@ fn prepare_payment(
|
|||
(real_fee, c)
|
||||
}
|
||||
// ADA-only path with sub-min change — fold into fee.
|
||||
Some(c) => (real_fee + c, 0),
|
||||
Some(c) => (
|
||||
real_fee
|
||||
.checked_add(c)
|
||||
.ok_or_else(|| WalletError::Derivation("fee + change overflow".into()))?,
|
||||
0,
|
||||
),
|
||||
None => {
|
||||
return Err(WalletError::Derivation(format!(
|
||||
"insufficient funds for fee: total_in={total_in_lovelace} lovelace={lovelace} fee={real_fee}"
|
||||
|
|
@ -536,28 +540,31 @@ fn prepare_payment(
|
|||
|
||||
// Re-shape the asset maps back into Vec<AssetSpec> for the
|
||||
// summary — easier for callers to display than a BTreeMap.
|
||||
// L-4 audit fix: replaced .expect() with proper error
|
||||
// propagation. Logic-bug paths (we built the key) become
|
||||
// typed errors instead of process-level panics.
|
||||
let send_assets_vec: Vec<AssetSpec> = target_assets
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
let (p, n) = split_asset_key(k).expect("we built this key");
|
||||
AssetSpec {
|
||||
let (p, n) = split_asset_key(k)?;
|
||||
Ok::<AssetSpec, WalletError>(AssetSpec {
|
||||
policy_id_hex: p.to_string(),
|
||||
asset_name_hex: n.to_string(),
|
||||
quantity: *v,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
.collect::<Result<_, _>>()?;
|
||||
let change_assets_vec: Vec<AssetSpec> = change_assets
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
let (p, n) = split_asset_key(k).expect("we built this key");
|
||||
AssetSpec {
|
||||
let (p, n) = split_asset_key(k)?;
|
||||
Ok::<AssetSpec, WalletError>(AssetSpec {
|
||||
policy_id_hex: p.to_string(),
|
||||
asset_name_hex: n.to_string(),
|
||||
quantity: *v,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
.collect::<Result<_, _>>()?;
|
||||
|
||||
let summary = PaymentSummary {
|
||||
tx_hash: hex_encode(&built.tx_hash.0),
|
||||
|
|
|
|||
|
|
@ -30,6 +30,59 @@ use aldabra_core::{Mnemonic, RootKey};
|
|||
use anyhow::{anyhow, Context, Result};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// Atomically create a file with `0o600` permissions and write the
|
||||
/// payload. Replaces the older `fs::write` + `chmod` two-step which
|
||||
/// had a TOCTOU window where the file existed with default umask
|
||||
/// perms (often `0o644`) before the chmod tightened it.
|
||||
/// (M-2 audit fix.)
|
||||
#[cfg(unix)]
|
||||
fn write_owner_only(path: &Path, payload: &[u8]) -> Result<()> {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut f = fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.with_context(|| format!("creating {}", path.display()))?;
|
||||
f.write_all(payload)?;
|
||||
f.sync_all().ok();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn write_owner_only(path: &Path, payload: &[u8]) -> Result<()> {
|
||||
fs::write(path, payload).with_context(|| format!("writing {}", path.display()))
|
||||
}
|
||||
|
||||
/// Read `ALDABRA_PASSPHRASE` env into a `Zeroizing<String>` so the
|
||||
/// in-process copy gets wiped when dropped. The env block itself
|
||||
/// isn't zeroizable — that's a documented headless tradeoff. (M-3
|
||||
/// audit fix.)
|
||||
fn passphrase_from_env() -> Option<Zeroizing<String>> {
|
||||
std::env::var("ALDABRA_PASSPHRASE").ok().map(Zeroizing::new)
|
||||
}
|
||||
|
||||
fn prompt_or_env_passphrase(confirm: bool) -> Result<Zeroizing<String>> {
|
||||
if let Some(p) = passphrase_from_env() {
|
||||
return Ok(p);
|
||||
}
|
||||
let p = Zeroizing::new(rpassword::prompt_password("set encryption passphrase: ")?);
|
||||
if confirm {
|
||||
let c = Zeroizing::new(rpassword::prompt_password("confirm passphrase: ")?);
|
||||
if *p != *c {
|
||||
return Err(anyhow!("passphrases did not match — re-run to retry"));
|
||||
}
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn unlock_passphrase() -> Result<Zeroizing<String>> {
|
||||
if let Some(p) = passphrase_from_env() {
|
||||
return Ok(p);
|
||||
}
|
||||
Ok(Zeroizing::new(rpassword::prompt_password("passphrase: ")?))
|
||||
}
|
||||
|
||||
const MNEMONIC_FILENAME: &str = "mnemonic.age";
|
||||
|
||||
/// Encrypt a mnemonic phrase with a passphrase. Pure — no I/O, no
|
||||
|
|
@ -88,15 +141,7 @@ pub fn load_or_create_root_key(data_dir: &Path) -> Result<RootKey> {
|
|||
if path.exists() {
|
||||
eprintln!("aldabra: unlocking mnemonic at {}", path.display());
|
||||
let blob = fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
|
||||
// Headless-friendly: if ALDABRA_PASSPHRASE is set, use it
|
||||
// and skip the prompt. Required when the daemon runs under
|
||||
// an MCP client that owns stdin. Caller is responsible for
|
||||
// sourcing the env from a secure place (systemd
|
||||
// EnvironmentFile, docker secret, etc.).
|
||||
let passphrase = match std::env::var("ALDABRA_PASSPHRASE") {
|
||||
Ok(p) => p,
|
||||
Err(_) => rpassword::prompt_password("passphrase: ")?,
|
||||
};
|
||||
let passphrase = unlock_passphrase()?;
|
||||
let phrase = decrypt_mnemonic(&blob, &passphrase)?;
|
||||
let mnemonic = Mnemonic::from_phrase(&phrase)?;
|
||||
Ok(mnemonic.into_root_key()?)
|
||||
|
|
@ -118,24 +163,9 @@ pub fn load_or_create_root_key(data_dir: &Path) -> Result<RootKey> {
|
|||
// Validate before asking for passphrase — fail fast on bad input.
|
||||
Mnemonic::from_phrase(trimmed)?;
|
||||
|
||||
// Headless-friendly: if ALDABRA_PASSPHRASE is set, use it
|
||||
// for the bootstrap passphrase too. No confirm loop in that
|
||||
// case — the env var IS the source of truth.
|
||||
let passphrase = match std::env::var("ALDABRA_PASSPHRASE") {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
let p = rpassword::prompt_password("set encryption passphrase: ")?;
|
||||
let confirm = rpassword::prompt_password("confirm passphrase: ")?;
|
||||
if p != confirm {
|
||||
return Err(anyhow!("passphrases did not match — re-run to retry"));
|
||||
}
|
||||
p
|
||||
}
|
||||
};
|
||||
|
||||
let passphrase = prompt_or_env_passphrase(true)?;
|
||||
let blob = encrypt_mnemonic(trimmed, &passphrase)?;
|
||||
fs::write(&path, &blob).with_context(|| format!("writing {}", path.display()))?;
|
||||
restrict_to_owner(&path)?;
|
||||
write_owner_only(&path, &blob)?;
|
||||
eprintln!("aldabra: mnemonic encrypted to {}", path.display());
|
||||
|
||||
let mnemonic = Mnemonic::from_phrase(trimmed)?;
|
||||
|
|
@ -143,18 +173,58 @@ pub fn load_or_create_root_key(data_dir: &Path) -> Result<RootKey> {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn restrict_to_owner(path: &Path) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = fs::metadata(path)?.permissions();
|
||||
perms.set_mode(0o600);
|
||||
fs::set_permissions(path, perms).context("chmod 600")?;
|
||||
/// Print a freshly generated 24-word mnemonic to stderr and exit.
|
||||
/// Read-only — does not touch disk. The user writes the phrase down
|
||||
/// (cold metal, paper, whatever) then re-runs `aldabra --bootstrap`
|
||||
/// to import it, OR uses `aldabra --bootstrap-new` for one-shot
|
||||
/// generate-and-encrypt.
|
||||
pub fn print_fresh_mnemonic() -> Result<()> {
|
||||
let (_mnemonic, phrase) = Mnemonic::generate()?;
|
||||
eprintln!("================ ALDABRA: NEW 24-WORD MNEMONIC ================");
|
||||
eprintln!();
|
||||
eprintln!("{}", phrase.as_str());
|
||||
eprintln!();
|
||||
eprintln!("WRITE THIS DOWN. It is the ONLY recovery path for this wallet.");
|
||||
eprintln!("Anyone with this phrase can spend the wallet's funds.");
|
||||
eprintln!();
|
||||
eprintln!("To import: run `aldabra --bootstrap` and paste this phrase.");
|
||||
eprintln!("===============================================================");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn restrict_to_owner(_path: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
/// Generate a fresh mnemonic, display it for the user to write down,
|
||||
/// then encrypt + persist it. Returns the derived [`RootKey`] so the
|
||||
/// caller can continue with address derivation. Combines what
|
||||
/// `print_fresh_mnemonic` + the import path of
|
||||
/// `load_or_create_root_key` would do separately.
|
||||
pub fn generate_and_save_root_key(data_dir: &Path) -> Result<RootKey> {
|
||||
let path = mnemonic_path(data_dir);
|
||||
if path.exists() {
|
||||
return Err(anyhow!(
|
||||
"mnemonic already exists at {} — refusing to overwrite. \
|
||||
remove the file or use a different ALDABRA_DATA dir.",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
fs::create_dir_all(data_dir)
|
||||
.with_context(|| format!("creating {}", data_dir.display()))?;
|
||||
|
||||
let (mnemonic, phrase) = Mnemonic::generate()?;
|
||||
eprintln!("================ ALDABRA: NEW 24-WORD MNEMONIC ================");
|
||||
eprintln!();
|
||||
eprintln!("{}", phrase.as_str());
|
||||
eprintln!();
|
||||
eprintln!("WRITE THIS DOWN. It is the ONLY recovery path for this wallet.");
|
||||
eprintln!("Anyone with this phrase can spend the wallet's funds.");
|
||||
eprintln!("===============================================================");
|
||||
eprintln!();
|
||||
|
||||
let passphrase = prompt_or_env_passphrase(true)?;
|
||||
let blob = encrypt_mnemonic(&phrase, &passphrase)?;
|
||||
write_owner_only(&path, &blob)?;
|
||||
eprintln!("aldabra: mnemonic encrypted to {}", path.display());
|
||||
|
||||
Ok(mnemonic.into_root_key()?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -58,33 +58,57 @@ async fn run() -> Result<()> {
|
|||
"aldabra starting"
|
||||
);
|
||||
|
||||
// First-run bootstrap reads the mnemonic from stdin, which would
|
||||
// collide with the MCP transport once `serve()` runs. So
|
||||
// bootstrap is gated behind a `--bootstrap` arg: do that once
|
||||
// out-of-band, then start the daemon normally.
|
||||
let bootstrap_only = std::env::args().any(|a| a == "--bootstrap");
|
||||
let mnemonic_path = bootstrap::mnemonic_path(&cfg.data_dir);
|
||||
// CLI mode flags — all out-of-band, before the MCP transport
|
||||
// takes over stdio.
|
||||
//
|
||||
// `--generate-mnemonic` — print a fresh phrase, exit. No disk write.
|
||||
// `--bootstrap` — paste an existing phrase, encrypt, derive.
|
||||
// `--bootstrap-new` — generate, display, encrypt, derive (one shot).
|
||||
// (none) — load existing, start MCP server.
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let generate_only = args.iter().any(|a| a == "--generate-mnemonic");
|
||||
let bootstrap_only = args.iter().any(|a| a == "--bootstrap");
|
||||
let bootstrap_new = args.iter().any(|a| a == "--bootstrap-new");
|
||||
|
||||
if !mnemonic_path.exists() && !bootstrap_only {
|
||||
anyhow::bail!(
|
||||
"no mnemonic at {}. run `aldabra --bootstrap` first to set one up.",
|
||||
mnemonic_path.display()
|
||||
);
|
||||
if generate_only {
|
||||
bootstrap::print_fresh_mnemonic()?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let root = bootstrap::load_or_create_root_key(&cfg.data_dir)?;
|
||||
let address = aldabra_core::derive_base_address(
|
||||
&root,
|
||||
cfg.network,
|
||||
cfg.account,
|
||||
cfg.index,
|
||||
)?;
|
||||
let payment_key =
|
||||
aldabra_core::derive_payment_key(&root, cfg.account, cfg.index);
|
||||
let stake_key = aldabra_core::derive_stake_key(&root, cfg.account);
|
||||
let mnemonic_path = bootstrap::mnemonic_path(&cfg.data_dir);
|
||||
|
||||
// L-2 audit fix: scope `root` to a block so its XPrv drops + wipes
|
||||
// as soon as we've extracted the keys we need.
|
||||
let (payment_key, stake_key, address) = {
|
||||
let root = if bootstrap_new {
|
||||
bootstrap::generate_and_save_root_key(&cfg.data_dir)?
|
||||
} else if mnemonic_path.exists() {
|
||||
bootstrap::load_or_create_root_key(&cfg.data_dir)?
|
||||
} else if bootstrap_only {
|
||||
bootstrap::load_or_create_root_key(&cfg.data_dir)?
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"no mnemonic at {}. run `aldabra --bootstrap` (paste existing) \
|
||||
or `aldabra --bootstrap-new` (generate fresh) first.",
|
||||
mnemonic_path.display()
|
||||
);
|
||||
};
|
||||
|
||||
let address = aldabra_core::derive_base_address(
|
||||
&root,
|
||||
cfg.network,
|
||||
cfg.account,
|
||||
cfg.index,
|
||||
)?;
|
||||
let payment_key =
|
||||
aldabra_core::derive_payment_key(&root, cfg.account, cfg.index);
|
||||
let stake_key = aldabra_core::derive_stake_key(&root, cfg.account);
|
||||
(payment_key, stake_key, address)
|
||||
// root drops here — XPrv::Drop wipes the 96 bytes
|
||||
};
|
||||
tracing::info!(%address, "derived base address");
|
||||
|
||||
if bootstrap_only {
|
||||
if bootstrap_only || bootstrap_new {
|
||||
eprintln!("aldabra: bootstrap complete. address = {address}");
|
||||
return Ok(());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,9 +28,9 @@ use aldabra_chain::{ChainBackend, KoiosClient};
|
|||
use aldabra_core::{
|
||||
add_witness, build_signed_cip68_nft_mint, build_signed_mint_with_metadata,
|
||||
build_signed_payment_with_assets, build_signed_plutus_spend, build_signed_stake_delegation,
|
||||
build_unsigned_mint, build_unsigned_payment_with_assets, hex_decode, AssetSpec, InputUtxo,
|
||||
Network, PaymentKey, PlutusExUnits, PlutusInput, PlutusVersion, PolicySpec, ProtocolParams,
|
||||
StakeKey, DEFAULT_EX_UNITS,
|
||||
build_unsigned_mint, build_unsigned_payment_with_assets, hex_decode, summarize_tx, AssetSpec,
|
||||
InputUtxo, Network, PaymentKey, PlutusExUnits, PlutusInput, PlutusVersion, PolicySpec,
|
||||
ProtocolParams, StakeKey, DEFAULT_EX_UNITS,
|
||||
};
|
||||
use rmcp::{model::ServerInfo, schemars, tool, ServerHandler};
|
||||
use serde::Deserialize;
|
||||
|
|
@ -91,6 +91,21 @@ impl WalletService {
|
|||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reject if `lovelace` exceeds the wallet's hard cap unless
|
||||
/// `force=true`. Used by every tool that moves lovelace to a
|
||||
/// non-wallet destination — wallet.send, wallet.mint,
|
||||
/// 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> {
|
||||
if lovelace > self.inner.max_send_lovelace && !force {
|
||||
return Err(format!(
|
||||
"lovelace {lovelace} exceeds max_send_lovelace {}; pass force=true to override",
|
||||
self.inner.max_send_lovelace
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
|
|
@ -195,6 +210,10 @@ pub struct ScriptSpendArgs {
|
|||
/// validators; tune for real ones).
|
||||
#[serde(default)]
|
||||
pub ex_units: Option<ExUnitsArg>,
|
||||
/// Bypass the hard cap on `payout_lovelace`. Required if a
|
||||
/// Plutus spend unlocks a large UTXO and routes it elsewhere.
|
||||
#[serde(default)]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
|
|
@ -219,6 +238,16 @@ fn default_register_first() -> bool {
|
|||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub struct TxSummaryArgs {
|
||||
/// Hex-encoded Conway-era tx CBOR — unsigned, partially-signed,
|
||||
/// or fully signed. Decoded read-only and turned into a
|
||||
/// human-reviewable JSON summary. **Always run this before
|
||||
/// `wallet.sign_partial` or `wallet.submit_signed_tx` on a
|
||||
/// CBOR you didn't build yourself.**
|
||||
pub cbor_hex: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub struct SignPartialArgs {
|
||||
/// Hex-encoded Conway-era tx CBOR — unsigned, or already
|
||||
|
|
@ -258,6 +287,11 @@ pub struct Cip68NftArgs {
|
|||
/// wallet's payment key.
|
||||
#[serde(default)]
|
||||
pub invalid_after_slot: Option<u64>,
|
||||
/// Bypass the hard cap (`max_send_lovelace`) on the sum of
|
||||
/// `user_lovelace + ref_lovelace`. Defaults small but a
|
||||
/// user-confirmed large NFT mint can override.
|
||||
#[serde(default)]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
fn default_token_lovelace() -> u64 {
|
||||
|
|
@ -288,6 +322,11 @@ pub struct MintArgs {
|
|||
/// rendering the asset.
|
||||
#[serde(default)]
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
/// Bypass the configured `max_send_lovelace` hard cap on
|
||||
/// `dest_lovelace`. Only pass `true` for an intentional,
|
||||
/// user-confirmed large mint output.
|
||||
#[serde(default)]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
#[tool(tool_box)]
|
||||
|
|
@ -367,12 +406,7 @@ impl WalletService {
|
|||
if lovelace == 0 {
|
||||
return Err("lovelace must be > 0".into());
|
||||
}
|
||||
if lovelace > self.inner.max_send_lovelace && !force {
|
||||
return Err(format!(
|
||||
"lovelace {lovelace} exceeds max_send_lovelace {}; pass force=true to override",
|
||||
self.inner.max_send_lovelace
|
||||
));
|
||||
}
|
||||
self.enforce_value_cap(lovelace, force)?;
|
||||
|
||||
let utxos = self
|
||||
.inner
|
||||
|
|
@ -548,6 +582,7 @@ impl WalletService {
|
|||
quantity,
|
||||
invalid_after_slot,
|
||||
metadata,
|
||||
force,
|
||||
}: MintArgs,
|
||||
) -> Result<String, String> {
|
||||
if quantity == 0 {
|
||||
|
|
@ -558,6 +593,9 @@ impl WalletService {
|
|||
"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)?;
|
||||
|
||||
let utxos = self
|
||||
.inner
|
||||
|
|
@ -625,11 +663,19 @@ impl WalletService {
|
|||
ref_address,
|
||||
ref_lovelace,
|
||||
invalid_after_slot,
|
||||
force,
|
||||
}: Cip68NftArgs,
|
||||
) -> Result<String, String> {
|
||||
if user_lovelace < 1_000_000 || ref_lovelace < 1_000_000 {
|
||||
return Err("user_lovelace and ref_lovelace must each be ≥ 1 ADA".into());
|
||||
}
|
||||
// HIGH-1: cap on the total lovelace that leaves the wallet
|
||||
// toward non-self destinations. Sum the two outputs; if either
|
||||
// overflows, also reject.
|
||||
let total = user_lovelace
|
||||
.checked_add(ref_lovelace)
|
||||
.ok_or("user_lovelace + ref_lovelace overflow")?;
|
||||
self.enforce_value_cap(total, force)?;
|
||||
let name_body = hex_decode(&name_body_hex).map_err(|e| format!("name_body_hex: {e}"))?;
|
||||
if name_body.len() > 28 {
|
||||
return Err(format!(
|
||||
|
|
@ -708,8 +754,12 @@ impl WalletService {
|
|||
payout_address,
|
||||
payout_lovelace,
|
||||
ex_units,
|
||||
force,
|
||||
}: ScriptSpendArgs,
|
||||
) -> 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)?;
|
||||
let version = match plutus_version.to_ascii_lowercase().as_str() {
|
||||
"v1" => PlutusVersion::V1,
|
||||
"v2" => PlutusVersion::V2,
|
||||
|
|
@ -925,6 +975,19 @@ impl WalletService {
|
|||
serde_json::to_string(&unsigned).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "wallet.tx_summary",
|
||||
description = "Decode a Conway-era tx CBOR (unsigned, partial, or signed) into a human-reviewable JSON summary: tx_hash, inputs count, outputs (address+lovelace+assets+inline_datum flag), fee, certificates, mint, witness count, aux-data presence. **Read-only — does not sign or submit.** Run this before `wallet.sign_partial` on any CBOR you didn't build yourself."
|
||||
)]
|
||||
async fn wallet_tx_summary(
|
||||
&self,
|
||||
#[tool(aggr)] TxSummaryArgs { cbor_hex }: TxSummaryArgs,
|
||||
) -> Result<String, String> {
|
||||
let bytes = hex_decode(&cbor_hex).map_err(|e| format!("decode: {e}"))?;
|
||||
let summary = summarize_tx(&bytes).map_err(|e| format!("summarize: {e}"))?;
|
||||
serde_json::to_string(&summary).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "wallet.sign_partial",
|
||||
description = "Append this wallet's VKeyWitness to a Conway-era tx (unsigned or partially-signed). Args: cbor_hex (hex-encoded tx CBOR). Returns the updated CBOR hex with our signature added. For multi-sig flows (e.g. ADAMaps treasury 2-of-2): each party calls this in turn, then any party submits via wallet.submit_signed_tx."
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue