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