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:
Sulkta 2026-05-04 14:52:08 -07:00
parent 5888d37df6
commit d1cc77969f
12 changed files with 696 additions and 126 deletions

View 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());
}
}

View file

@ -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();

View file

@ -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.

View file

@ -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();

View file

@ -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),