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 internal notes updated with
status markers.
This commit is contained in:
parent
5888d37df6
commit
d1cc77969f
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"
|
checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitcoin_hashes",
|
"bitcoin_hashes",
|
||||||
|
"rand 0.8.6",
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
"serde",
|
"serde",
|
||||||
"unicode-normalization",
|
"unicode-normalization",
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,8 @@ pallas-network = "0.32"
|
||||||
# (CIP-3). Already pulled in transitively by
|
# (CIP-3). Already pulled in transitively by
|
||||||
# ed25519-bip32; declared here so we can use pbkdf2 + Sha512
|
# ed25519-bip32; declared here so we can use pbkdf2 + Sha512
|
||||||
# directly in aldabra-core.
|
# 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"
|
ed25519-bip32 = "0.4"
|
||||||
cryptoxide = "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:?})")))
|
.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 {
|
fn asset_key(policy_id: &str, asset_name_hex: &str) -> String {
|
||||||
let mut k = String::with_capacity(policy_id.len() + asset_name_hex.len());
|
let mut k = String::with_capacity(policy_id.len() + asset_name_hex.len());
|
||||||
k.push_str(policy_id);
|
k.push_str(policy_id);
|
||||||
|
|
@ -205,18 +212,30 @@ impl ChainBackend for KoiosClient {
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ChainError::Decode(e.to_string()))?;
|
.map_err(|e| ChainError::Decode(e.to_string()))?;
|
||||||
// Koios returns the tx hash as a quoted JSON string. Strip the
|
// Koios returns the tx hash as a quoted JSON string. Strip the
|
||||||
// surrounding quotes if present.
|
// surrounding quotes if present, then validate the result is
|
||||||
Ok(body.trim().trim_matches('"').to_string())
|
// 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> {
|
async fn tx_status(&self, tx_hash: &str) -> Result<TxStatus, ChainError> {
|
||||||
let body = TxHashesBody { tx_hashes: vec![tx_hash] };
|
let body = TxHashesBody { tx_hashes: vec![tx_hash] };
|
||||||
let raw: Vec<KoiosTxInfo> = self.post_json("tx_info", &body).await?;
|
let raw: Vec<KoiosTxInfo> = self.post_json("tx_info", &body).await?;
|
||||||
match raw.into_iter().next() {
|
match raw.into_iter().next() {
|
||||||
Some(info) => Ok(TxStatus::Confirmed {
|
Some(info) => match info.block_height {
|
||||||
block_height: info.block_height,
|
Some(h) => Ok(TxStatus::Confirmed {
|
||||||
epoch: info.epoch_no,
|
block_height: h,
|
||||||
}),
|
epoch: info.epoch_no,
|
||||||
|
}),
|
||||||
|
None => Ok(TxStatus::Pending),
|
||||||
|
},
|
||||||
None => Ok(TxStatus::NotFound),
|
None => Ok(TxStatus::NotFound),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -342,6 +361,16 @@ mod tests {
|
||||||
assert_eq!(assets.get("ee0a1234deadbeef"), Some(&123));
|
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]
|
#[test]
|
||||||
fn parse_u64_rejects_garbage() {
|
fn parse_u64_rejects_garbage() {
|
||||||
let err = parse_u64("not-a-number", "test").unwrap_err();
|
let err = parse_u64("not-a-number", "test").unwrap_err();
|
||||||
|
|
@ -384,15 +413,19 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn tx_status_serializes_with_tag() {
|
fn tx_status_serializes_with_tag() {
|
||||||
let confirmed = TxStatus::Confirmed {
|
let confirmed = TxStatus::Confirmed {
|
||||||
block_height: Some(100),
|
block_height: 100,
|
||||||
epoch: Some(5),
|
epoch: Some(5),
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&confirmed).unwrap();
|
let json = serde_json::to_string(&confirmed).unwrap();
|
||||||
assert!(json.contains("\"status\":\"confirmed\""));
|
assert!(json.contains("\"status\":\"confirmed\""));
|
||||||
assert!(json.contains("\"block_height\":100"));
|
assert!(json.contains("\"block_height\":100"));
|
||||||
|
|
||||||
let pending = TxStatus::NotFound;
|
let pending = TxStatus::Pending;
|
||||||
let json = serde_json::to_string(&pending).unwrap();
|
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\""));
|
assert!(json.contains("\"status\":\"not_found\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,13 +65,19 @@ pub struct Balance {
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(tag = "status", rename_all = "snake_case")]
|
#[serde(tag = "status", rename_all = "snake_case")]
|
||||||
pub enum TxStatus {
|
pub enum TxStatus {
|
||||||
/// Confirmed on-chain. `block_height` and `epoch` are populated
|
/// Confirmed on-chain with a block height.
|
||||||
/// when Koios returns them.
|
|
||||||
Confirmed {
|
Confirmed {
|
||||||
block_height: Option<u64>,
|
block_height: u64,
|
||||||
epoch: Option<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,
|
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,
|
Network as PallasNetwork, ShelleyAddress, ShelleyDelegationPart, ShelleyPaymentPart,
|
||||||
};
|
};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use zeroize::ZeroizeOnDrop;
|
use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
|
||||||
|
|
||||||
pub mod cip68;
|
pub mod cip68;
|
||||||
pub mod derive;
|
pub mod derive;
|
||||||
|
pub mod inspect;
|
||||||
pub mod metadata;
|
pub mod metadata;
|
||||||
pub mod mint;
|
pub mod mint;
|
||||||
pub mod plutus;
|
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,
|
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 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.
|
// Stake address derivation lives directly on StakeKey — exported above.
|
||||||
pub use metadata::{build_cip25_aux_data, CIP25_LABEL};
|
pub use metadata::{build_cip25_aux_data, CIP25_LABEL};
|
||||||
pub use mint::{
|
pub use mint::{
|
||||||
|
|
@ -95,6 +97,27 @@ pub struct Mnemonic {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl 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.
|
/// Parse a 24-word English mnemonic, validating word count + checksum.
|
||||||
/// Drops the source phrase reference immediately after extracting
|
/// Drops the source phrase reference immediately after extracting
|
||||||
/// entropy.
|
/// entropy.
|
||||||
|
|
@ -143,6 +166,11 @@ impl Mnemonic {
|
||||||
pbkdf2(&mut hmac, &self.entropy, 4096, &mut xprv_bytes);
|
pbkdf2(&mut hmac, &self.entropy, 4096, &mut xprv_bytes);
|
||||||
|
|
||||||
let xprv = XPrv::normalize_bytes_force3rd(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 })
|
Ok(RootKey { xprv })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -269,6 +297,25 @@ mod tests {
|
||||||
assert_eq!(m.entropy, [0u8; 32]);
|
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]
|
#[test]
|
||||||
fn derives_root_key_from_canonical_mnemonic() {
|
fn derives_root_key_from_canonical_mnemonic() {
|
||||||
let m = Mnemonic::from_phrase(ABANDON_ART).unwrap();
|
let m = Mnemonic::from_phrase(ABANDON_ART).unwrap();
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,7 @@
|
||||||
//! follow-ups. ExUnits today come from the caller.
|
//! follow-ups. ExUnits today come from the caller.
|
||||||
|
|
||||||
use bech32::FromBase32;
|
use bech32::FromBase32;
|
||||||
use pallas_codec::minicbor;
|
|
||||||
use pallas_crypto::hash::Hash;
|
use pallas_crypto::hash::Hash;
|
||||||
use pallas_primitives::Fragment;
|
|
||||||
use pallas_txbuilder::{BuildConway, ExUnits, Input, Output, ScriptKind, StagingTransaction};
|
use pallas_txbuilder::{BuildConway, ExUnits, Input, Output, ScriptKind, StagingTransaction};
|
||||||
|
|
||||||
use crate::sign::add_witness;
|
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))
|
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 {
|
fn network_id_for(network: Network) -> u8 {
|
||||||
match network {
|
match network {
|
||||||
Network::Mainnet => 1,
|
Network::Mainnet => 1,
|
||||||
|
|
@ -363,6 +347,7 @@ mod tests {
|
||||||
.expect("plutus spend builds + signs");
|
.expect("plutus spend builds + signs");
|
||||||
assert!(cbor.len() > 200);
|
assert!(cbor.len() > 200);
|
||||||
|
|
||||||
|
use pallas_primitives::Fragment;
|
||||||
let tx = pallas_primitives::conway::Tx::decode_fragment(&cbor)
|
let tx = pallas_primitives::conway::Tx::decode_fragment(&cbor)
|
||||||
.expect("decode plutus spend cbor");
|
.expect("decode plutus spend cbor");
|
||||||
// Inputs include the locked UTXO + collateral.
|
// 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::conway::{Tx, VKeyWitness};
|
||||||
use pallas_primitives::Fragment;
|
use pallas_primitives::Fragment;
|
||||||
use pallas_traverse::ComputeHash;
|
use pallas_traverse::ComputeHash;
|
||||||
|
use zeroize::Zeroize;
|
||||||
|
|
||||||
use crate::{PaymentKey, WalletError};
|
use crate::{PaymentKey, WalletError};
|
||||||
|
|
||||||
|
|
@ -46,9 +47,13 @@ pub fn add_witness(
|
||||||
// encoder).
|
// encoder).
|
||||||
let body_hash = tx.transaction_body.compute_hash();
|
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)
|
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 signature = secret.sign(body_hash.as_ref());
|
||||||
let pubkey = secret.public_key();
|
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_txbuilder::{BuildConway, BuiltTransaction, Input, Output, StagingTransaction};
|
||||||
use pallas_wallet::PrivateKey;
|
use pallas_wallet::PrivateKey;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use zeroize::Zeroize;
|
||||||
|
|
||||||
use crate::{Network, PaymentKey, WalletError};
|
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
|
/// Convert a [`PaymentKey`] into a `pallas-wallet::PrivateKey` so
|
||||||
/// `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
|
||||||
|
/// 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> {
|
fn payment_key_to_private(payment: &PaymentKey) -> Result<PrivateKey, WalletError> {
|
||||||
let xprv: &XPrv = payment.xprv();
|
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)
|
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))
|
Ok(PrivateKey::Extended(secret))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -397,18 +407,6 @@ fn build_unsigned_bytes(
|
||||||
Ok(built.tx_bytes.0)
|
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
|
/// Internal helper — runs the two-pass fee refinement and returns
|
||||||
/// the final `BuiltTransaction` plus a `PaymentSummary` describing
|
/// the final `BuiltTransaction` plus a `PaymentSummary` describing
|
||||||
|
|
@ -495,12 +493,13 @@ 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();
|
||||||
|
|
||||||
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 => {
|
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 {
|
if change_must_exist && c < params.min_utxo_lovelace {
|
||||||
return Err(WalletError::Derivation(format!(
|
return Err(WalletError::Derivation(format!(
|
||||||
"insufficient ADA for token-bearing change output: change={c} lovelace, min={}",
|
"insufficient ADA for token-bearing change output: change={c} lovelace, min={}",
|
||||||
|
|
@ -510,7 +509,12 @@ fn prepare_payment(
|
||||||
(real_fee, c)
|
(real_fee, c)
|
||||||
}
|
}
|
||||||
// ADA-only path with sub-min change — fold into fee.
|
// 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 => {
|
None => {
|
||||||
return Err(WalletError::Derivation(format!(
|
return Err(WalletError::Derivation(format!(
|
||||||
"insufficient funds for fee: total_in={total_in_lovelace} lovelace={lovelace} fee={real_fee}"
|
"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
|
// 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
|
||||||
|
// propagation. Logic-bug paths (we built the key) become
|
||||||
|
// 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)| {
|
||||||
let (p, n) = split_asset_key(k).expect("we built this key");
|
let (p, n) = split_asset_key(k)?;
|
||||||
AssetSpec {
|
Ok::<AssetSpec, WalletError>(AssetSpec {
|
||||||
policy_id_hex: p.to_string(),
|
policy_id_hex: p.to_string(),
|
||||||
asset_name_hex: n.to_string(),
|
asset_name_hex: n.to_string(),
|
||||||
quantity: *v,
|
quantity: *v,
|
||||||
}
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect::<Result<_, _>>()?;
|
||||||
let change_assets_vec: Vec<AssetSpec> = change_assets
|
let change_assets_vec: Vec<AssetSpec> = change_assets
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(k, v)| {
|
.map(|(k, v)| {
|
||||||
let (p, n) = split_asset_key(k).expect("we built this key");
|
let (p, n) = split_asset_key(k)?;
|
||||||
AssetSpec {
|
Ok::<AssetSpec, WalletError>(AssetSpec {
|
||||||
policy_id_hex: p.to_string(),
|
policy_id_hex: p.to_string(),
|
||||||
asset_name_hex: n.to_string(),
|
asset_name_hex: n.to_string(),
|
||||||
quantity: *v,
|
quantity: *v,
|
||||||
}
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect::<Result<_, _>>()?;
|
||||||
|
|
||||||
let summary = PaymentSummary {
|
let summary = PaymentSummary {
|
||||||
tx_hash: hex_encode(&built.tx_hash.0),
|
tx_hash: hex_encode(&built.tx_hash.0),
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,59 @@ use aldabra_core::{Mnemonic, RootKey};
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use zeroize::Zeroizing;
|
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";
|
const MNEMONIC_FILENAME: &str = "mnemonic.age";
|
||||||
|
|
||||||
/// Encrypt a mnemonic phrase with a passphrase. Pure — no I/O, no
|
/// 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() {
|
if path.exists() {
|
||||||
eprintln!("aldabra: unlocking mnemonic at {}", path.display());
|
eprintln!("aldabra: unlocking mnemonic at {}", path.display());
|
||||||
let blob = fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
|
let blob = fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
|
||||||
// Headless-friendly: if ALDABRA_PASSPHRASE is set, use it
|
let passphrase = unlock_passphrase()?;
|
||||||
// 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 phrase = decrypt_mnemonic(&blob, &passphrase)?;
|
let phrase = decrypt_mnemonic(&blob, &passphrase)?;
|
||||||
let mnemonic = Mnemonic::from_phrase(&phrase)?;
|
let mnemonic = Mnemonic::from_phrase(&phrase)?;
|
||||||
Ok(mnemonic.into_root_key()?)
|
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.
|
// Validate before asking for passphrase — fail fast on bad input.
|
||||||
Mnemonic::from_phrase(trimmed)?;
|
Mnemonic::from_phrase(trimmed)?;
|
||||||
|
|
||||||
// Headless-friendly: if ALDABRA_PASSPHRASE is set, use it
|
let passphrase = prompt_or_env_passphrase(true)?;
|
||||||
// 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 blob = encrypt_mnemonic(trimmed, &passphrase)?;
|
let blob = encrypt_mnemonic(trimmed, &passphrase)?;
|
||||||
fs::write(&path, &blob).with_context(|| format!("writing {}", path.display()))?;
|
write_owner_only(&path, &blob)?;
|
||||||
restrict_to_owner(&path)?;
|
|
||||||
eprintln!("aldabra: mnemonic encrypted to {}", path.display());
|
eprintln!("aldabra: mnemonic encrypted to {}", path.display());
|
||||||
|
|
||||||
let mnemonic = Mnemonic::from_phrase(trimmed)?;
|
let mnemonic = Mnemonic::from_phrase(trimmed)?;
|
||||||
|
|
@ -143,18 +173,58 @@ pub fn load_or_create_root_key(data_dir: &Path) -> Result<RootKey> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
/// Print a freshly generated 24-word mnemonic to stderr and exit.
|
||||||
fn restrict_to_owner(path: &Path) -> Result<()> {
|
/// Read-only — does not touch disk. The user writes the phrase down
|
||||||
use std::os::unix::fs::PermissionsExt;
|
/// (cold metal, paper, whatever) then re-runs `aldabra --bootstrap`
|
||||||
let mut perms = fs::metadata(path)?.permissions();
|
/// to import it, OR uses `aldabra --bootstrap-new` for one-shot
|
||||||
perms.set_mode(0o600);
|
/// generate-and-encrypt.
|
||||||
fs::set_permissions(path, perms).context("chmod 600")?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
/// Generate a fresh mnemonic, display it for the user to write down,
|
||||||
fn restrict_to_owner(_path: &Path) -> Result<()> {
|
/// then encrypt + persist it. Returns the derived [`RootKey`] so the
|
||||||
Ok(())
|
/// 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)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
|
|
@ -58,33 +58,57 @@ async fn run() -> Result<()> {
|
||||||
"aldabra starting"
|
"aldabra starting"
|
||||||
);
|
);
|
||||||
|
|
||||||
// First-run bootstrap reads the mnemonic from stdin, which would
|
// CLI mode flags — all out-of-band, before the MCP transport
|
||||||
// collide with the MCP transport once `serve()` runs. So
|
// takes over stdio.
|
||||||
// bootstrap is gated behind a `--bootstrap` arg: do that once
|
//
|
||||||
// out-of-band, then start the daemon normally.
|
// `--generate-mnemonic` — print a fresh phrase, exit. No disk write.
|
||||||
let bootstrap_only = std::env::args().any(|a| a == "--bootstrap");
|
// `--bootstrap` — paste an existing phrase, encrypt, derive.
|
||||||
let mnemonic_path = bootstrap::mnemonic_path(&cfg.data_dir);
|
// `--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 {
|
if generate_only {
|
||||||
anyhow::bail!(
|
bootstrap::print_fresh_mnemonic()?;
|
||||||
"no mnemonic at {}. run `aldabra --bootstrap` first to set one up.",
|
return Ok(());
|
||||||
mnemonic_path.display()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let root = bootstrap::load_or_create_root_key(&cfg.data_dir)?;
|
let mnemonic_path = bootstrap::mnemonic_path(&cfg.data_dir);
|
||||||
let address = aldabra_core::derive_base_address(
|
|
||||||
&root,
|
// L-2 audit fix: scope `root` to a block so its XPrv drops + wipes
|
||||||
cfg.network,
|
// as soon as we've extracted the keys we need.
|
||||||
cfg.account,
|
let (payment_key, stake_key, address) = {
|
||||||
cfg.index,
|
let root = if bootstrap_new {
|
||||||
)?;
|
bootstrap::generate_and_save_root_key(&cfg.data_dir)?
|
||||||
let payment_key =
|
} else if mnemonic_path.exists() {
|
||||||
aldabra_core::derive_payment_key(&root, cfg.account, cfg.index);
|
bootstrap::load_or_create_root_key(&cfg.data_dir)?
|
||||||
let stake_key = aldabra_core::derive_stake_key(&root, cfg.account);
|
} 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");
|
tracing::info!(%address, "derived base address");
|
||||||
|
|
||||||
if bootstrap_only {
|
if bootstrap_only || bootstrap_new {
|
||||||
eprintln!("aldabra: bootstrap complete. address = {address}");
|
eprintln!("aldabra: bootstrap complete. address = {address}");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,9 @@ use aldabra_chain::{ChainBackend, KoiosClient};
|
||||||
use aldabra_core::{
|
use aldabra_core::{
|
||||||
add_witness, build_signed_cip68_nft_mint, build_signed_mint_with_metadata,
|
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_signed_payment_with_assets, build_signed_plutus_spend, build_signed_stake_delegation,
|
||||||
build_unsigned_mint, build_unsigned_payment_with_assets, hex_decode, AssetSpec, InputUtxo,
|
build_unsigned_mint, build_unsigned_payment_with_assets, hex_decode, summarize_tx, AssetSpec,
|
||||||
Network, PaymentKey, PlutusExUnits, PlutusInput, PlutusVersion, PolicySpec, ProtocolParams,
|
InputUtxo, Network, PaymentKey, PlutusExUnits, PlutusInput, PlutusVersion, PolicySpec,
|
||||||
StakeKey, DEFAULT_EX_UNITS,
|
ProtocolParams, StakeKey, DEFAULT_EX_UNITS,
|
||||||
};
|
};
|
||||||
use rmcp::{model::ServerInfo, schemars, tool, ServerHandler};
|
use rmcp::{model::ServerInfo, schemars, tool, ServerHandler};
|
||||||
use serde::Deserialize;
|
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)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
|
|
@ -195,6 +210,10 @@ pub struct ScriptSpendArgs {
|
||||||
/// validators; tune for real ones).
|
/// validators; tune for real ones).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ex_units: Option<ExUnitsArg>,
|
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)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
|
|
@ -219,6 +238,16 @@ fn default_register_first() -> bool {
|
||||||
true
|
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)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
pub struct SignPartialArgs {
|
pub struct SignPartialArgs {
|
||||||
/// Hex-encoded Conway-era tx CBOR — unsigned, or already
|
/// Hex-encoded Conway-era tx CBOR — unsigned, or already
|
||||||
|
|
@ -258,6 +287,11 @@ pub struct Cip68NftArgs {
|
||||||
/// wallet's payment key.
|
/// wallet's payment key.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub invalid_after_slot: Option<u64>,
|
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 {
|
fn default_token_lovelace() -> u64 {
|
||||||
|
|
@ -288,6 +322,11 @@ pub struct MintArgs {
|
||||||
/// rendering the asset.
|
/// rendering the asset.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub metadata: Option<serde_json::Value>,
|
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)]
|
#[tool(tool_box)]
|
||||||
|
|
@ -367,12 +406,7 @@ impl WalletService {
|
||||||
if lovelace == 0 {
|
if lovelace == 0 {
|
||||||
return Err("lovelace must be > 0".into());
|
return Err("lovelace must be > 0".into());
|
||||||
}
|
}
|
||||||
if lovelace > self.inner.max_send_lovelace && !force {
|
self.enforce_value_cap(lovelace, force)?;
|
||||||
return Err(format!(
|
|
||||||
"lovelace {lovelace} exceeds max_send_lovelace {}; pass force=true to override",
|
|
||||||
self.inner.max_send_lovelace
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let utxos = self
|
let utxos = self
|
||||||
.inner
|
.inner
|
||||||
|
|
@ -548,6 +582,7 @@ impl WalletService {
|
||||||
quantity,
|
quantity,
|
||||||
invalid_after_slot,
|
invalid_after_slot,
|
||||||
metadata,
|
metadata,
|
||||||
|
force,
|
||||||
}: MintArgs,
|
}: MintArgs,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
if quantity == 0 {
|
if quantity == 0 {
|
||||||
|
|
@ -558,6 +593,9 @@ 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)?;
|
||||||
|
|
||||||
let utxos = self
|
let utxos = self
|
||||||
.inner
|
.inner
|
||||||
|
|
@ -625,11 +663,19 @@ impl WalletService {
|
||||||
ref_address,
|
ref_address,
|
||||||
ref_lovelace,
|
ref_lovelace,
|
||||||
invalid_after_slot,
|
invalid_after_slot,
|
||||||
|
force,
|
||||||
}: Cip68NftArgs,
|
}: Cip68NftArgs,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
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
|
||||||
|
// 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}"))?;
|
let name_body = hex_decode(&name_body_hex).map_err(|e| format!("name_body_hex: {e}"))?;
|
||||||
if name_body.len() > 28 {
|
if name_body.len() > 28 {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
|
|
@ -708,8 +754,12 @@ impl WalletService {
|
||||||
payout_address,
|
payout_address,
|
||||||
payout_lovelace,
|
payout_lovelace,
|
||||||
ex_units,
|
ex_units,
|
||||||
|
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)?;
|
||||||
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,
|
||||||
"v2" => PlutusVersion::V2,
|
"v2" => PlutusVersion::V2,
|
||||||
|
|
@ -925,6 +975,19 @@ impl WalletService {
|
||||||
serde_json::to_string(&unsigned).map_err(|e| e.to_string())
|
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(
|
#[tool(
|
||||||
name = "wallet.sign_partial",
|
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. a 2-of-2 treasury): each party calls this in turn, then any party submits via wallet.submit_signed_tx."
|
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. a 2-of-2 treasury): 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