Drops the ~60 ticket-prefix comments (CRIT-N, HIGH-N, MED-N, LOW-N, L-N, M-N, AUDIT-N, PLUTUS-N, "audit fix (date):", "Phase N" labels, "Adversarial-review fix:") that had accumulated in inline + doc comments over several audit cycles. Where the surrounding prose still carried useful WHY context it gets kept and tightened; where the ticket WAS the comment it gets dropped entirely. No logic, no renames, no behavior change. Audit history lives in commit messages and the audits/ tree where it belongs — eternal comments don't need to mirror it. Net 138 LOC shorter. 253 tests pass, no new clippy or fmt warnings.
1364 lines
49 KiB
Rust
1364 lines
49 KiB
Rust
//! Transaction building + signing for the send path.
|
|
//!
|
|
//! Pure crypto + serialization — no I/O. Caller (typically
|
|
//! `aldabra-mcp`) is responsible for fetching UTXOs from the chain
|
|
//! backend and submitting the resulting CBOR bytes.
|
|
//!
|
|
//! ## What this does
|
|
//!
|
|
//! Given a [`PaymentKey`], a set of UTXOs at the wallet address, a
|
|
//! recipient address, and a lovelace amount:
|
|
//!
|
|
//! 1. Select inputs (greedy largest-first) covering target + fee + min change
|
|
//! 2. Build a Conway-era `StagingTransaction` with one or two outputs
|
|
//! (recipient + change, change collapsed into fee if sub-min)
|
|
//! 3. Estimate the min fee from `min_fee_a * tx_size + min_fee_b`,
|
|
//! rebuild once with the real fee
|
|
//! 4. Sign with the payment key
|
|
//! 5. Return the signed CBOR bytes ready for `submit_tx`
|
|
//!
|
|
//! ## What this doesn't do (yet)
|
|
//!
|
|
//! - Native asset sends (Phase 2.5)
|
|
//! - Plutus / script witnesses (Phase 4)
|
|
//! - Stake delegation (Phase 4)
|
|
//! - Live protocol-param fetching — we ship a hardcoded
|
|
//! [`ProtocolParams`] with the known Conway-era values. Callers can
|
|
//! override.
|
|
//!
|
|
//! ## Fee estimation
|
|
//!
|
|
//! Cardano's min-fee formula is `min_fee_a * tx_size_bytes + min_fee_b`.
|
|
//! We build twice: once with a placeholder fee to measure size, then
|
|
//! again with the real fee. The recipient amount is fixed; change
|
|
//! absorbs the difference. If change after fee is below `min_utxo` we
|
|
//! merge it into the fee (avoids creating dust UTXOs).
|
|
//!
|
|
//! ## Min-utxo
|
|
//!
|
|
//! Cardano protocol requires every output hold at least
|
|
//! `coins_per_utxo_byte * utxo_serialized_size` lovelace, which
|
|
//! works out to ~1 ADA for a plain ADA-only output. We approximate
|
|
//! conservatively at 1_000_000 lovelace until Phase 4 wires real
|
|
//! protocol params.
|
|
|
|
use ed25519_bip32::XPrv;
|
|
use pallas_addresses::Address as PallasAddress;
|
|
use pallas_crypto::key::ed25519::SecretKeyExtended;
|
|
use pallas_txbuilder::{
|
|
BuildConway, BuiltTransaction, Input, Output, ScriptKind, StagingTransaction,
|
|
};
|
|
|
|
/// Reference-script attached to a tx output. Used to deploy Plutus
|
|
/// validators / minting policies as reusable on-chain references so
|
|
/// downstream txs can spend from / mint under those scripts via
|
|
/// `--tx-in-script-file ref` semantics instead of inline-witnessing
|
|
/// the entire CBOR every time. Each ref-script carries its language
|
|
/// (PlutusV1/V2/V3 — Native is also valid but rare).
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct ReferenceScriptSpec<'a> {
|
|
pub kind: ScriptKind,
|
|
pub cbor: &'a [u8],
|
|
}
|
|
use pallas_wallet::PrivateKey;
|
|
use serde::{Deserialize, Serialize};
|
|
use zeroize::Zeroize;
|
|
|
|
use crate::{Network, PaymentKey, WalletError};
|
|
|
|
/// Cardano protocol parameters needed for fee estimation. Values
|
|
/// here match Conway-era mainnet as of 2026-Q2; supply your own via
|
|
/// [`ProtocolParams::from_koios_response`] (TODO) if you want
|
|
/// chain-fresh values.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ProtocolParams {
|
|
/// Per-byte fee coefficient (a). Mainnet: 44.
|
|
pub min_fee_a: u64,
|
|
/// Constant fee (b). Mainnet: 155_381.
|
|
pub min_fee_b: u64,
|
|
/// Approximate min lovelace for a plain ADA-only output. Real
|
|
/// formula is `coins_per_utxo_byte * tx_out_size`; we use the
|
|
/// 1 ADA floor as a safe over-approximation.
|
|
pub min_utxo_lovelace: u64,
|
|
/// Plutus ExUnits price per memory unit, as numerator/denominator
|
|
/// rational. Conway era: 577 / 10000 = 0.0577 lovelace/mem.
|
|
pub price_mem_numerator: u64,
|
|
pub price_mem_denominator: u64,
|
|
/// Plutus ExUnits price per CPU step, as numerator/denominator.
|
|
/// Conway era: 721 / 10_000_000 = 7.21e-5 lovelace/step.
|
|
pub price_steps_numerator: u64,
|
|
pub price_steps_denominator: u64,
|
|
/// Plutus V3 cost model. Required when building Plutus spend
|
|
/// transactions so the chain can verify `script_data_hash`. If
|
|
/// `None`, Plutus paths skip script_data_hash and the chain will
|
|
/// reject with `PPViewHashesDontMatch`.
|
|
pub plutus_v3_cost_model: Option<Vec<i64>>,
|
|
/// Conway DRep registration deposit (ledger param `drep_deposit`).
|
|
/// Mainnet default: 500 ADA. Used by `governance::build_signed_drep_*`.
|
|
/// Pass the chain's current value when constructing — registering
|
|
/// with the wrong amount fails ledger validation silently.
|
|
pub drep_deposit_lovelace: u64,
|
|
}
|
|
|
|
impl Default for ProtocolParams {
|
|
fn default() -> Self {
|
|
Self {
|
|
min_fee_a: 44,
|
|
min_fee_b: 155_381,
|
|
min_utxo_lovelace: 1_000_000,
|
|
price_mem_numerator: 577,
|
|
price_mem_denominator: 10_000,
|
|
price_steps_numerator: 721,
|
|
price_steps_denominator: 10_000_000,
|
|
// Plutus paths populate this from `plutus_cost_models::PLUTUS_V3_COST_MODEL_PREPROD`
|
|
// or fetched from `epoch_params`. None by default keeps
|
|
// the ada-only / mint paths zero-cost.
|
|
plutus_v3_cost_model: None,
|
|
drep_deposit_lovelace: 500_000_000,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ProtocolParams {
|
|
/// Linear size-based fee: `min_fee_a * tx_size + min_fee_b`.
|
|
pub fn min_fee_for_size(&self, tx_size_bytes: u64) -> u64 {
|
|
self.min_fee_a
|
|
.saturating_mul(tx_size_bytes)
|
|
.saturating_add(self.min_fee_b)
|
|
}
|
|
|
|
/// ExUnits execution cost in lovelace, ceiling-rounded per Conway
|
|
/// protocol rules. Add this to `min_fee_for_size(...)` for the
|
|
/// total fee on a Plutus transaction.
|
|
pub fn ex_units_fee(&self, mem: u64, steps: u64) -> u64 {
|
|
// ceil(mem * num / den)
|
|
let mem_cost = mem
|
|
.saturating_mul(self.price_mem_numerator)
|
|
.saturating_add(self.price_mem_denominator.saturating_sub(1))
|
|
/ self.price_mem_denominator.max(1);
|
|
let step_cost = steps
|
|
.saturating_mul(self.price_steps_numerator)
|
|
.saturating_add(self.price_steps_denominator.saturating_sub(1))
|
|
/ self.price_steps_denominator.max(1);
|
|
mem_cost.saturating_add(step_cost)
|
|
}
|
|
}
|
|
|
|
/// One UTXO available for spending. Independently typed from
|
|
/// `aldabra-chain::Utxo` so the tx-builder doesn't depend on the
|
|
/// chain crate (keeps the I/O-free contract intact).
|
|
///
|
|
/// `assets` keys follow Cardano's canonical
|
|
/// `<policy_id_hex(56)><asset_name_hex(0-64)>` concatenation —
|
|
/// matches `aldabra-chain::Utxo::assets`.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct InputUtxo {
|
|
pub tx_hash_hex: String,
|
|
pub output_index: u32,
|
|
pub lovelace: u64,
|
|
pub assets: std::collections::BTreeMap<String, u64>,
|
|
}
|
|
|
|
/// One native asset to include in an output, identified by policy +
|
|
/// asset-name. Both fields are hex-encoded.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct AssetSpec {
|
|
/// 56-char hex (28 bytes) Cardano policy ID.
|
|
pub policy_id_hex: String,
|
|
/// Hex-encoded asset name (raw bytes hex), 0-64 chars (0-32 bytes).
|
|
pub asset_name_hex: String,
|
|
pub quantity: u64,
|
|
}
|
|
|
|
impl AssetSpec {
|
|
/// Combined asset key in the same `policy||name` shape used by
|
|
/// `InputUtxo::assets` and `aldabra-chain::Utxo::assets`.
|
|
pub fn key(&self) -> String {
|
|
let mut s = String::with_capacity(self.policy_id_hex.len() + self.asset_name_hex.len());
|
|
s.push_str(&self.policy_id_hex);
|
|
s.push_str(&self.asset_name_hex);
|
|
s
|
|
}
|
|
}
|
|
|
|
/// Split a `policy||name` asset key back into (policy_id_hex,
|
|
/// asset_name_hex). Policy IDs are always exactly 56 hex chars
|
|
/// (28-byte Blake2b-224 of the policy script), so we split there.
|
|
fn split_asset_key(key: &str) -> Result<(&str, &str), WalletError> {
|
|
if key.len() < 56 {
|
|
return Err(WalletError::Derivation(format!(
|
|
"asset key too short: expected ≥56 hex chars, got {}",
|
|
key.len()
|
|
)));
|
|
}
|
|
Ok(key.split_at(56))
|
|
}
|
|
|
|
fn parse_policy_id(hex_str: &str) -> Result<pallas_crypto::hash::Hash<28>, WalletError> {
|
|
if hex_str.len() != 56 {
|
|
return Err(WalletError::Derivation(format!(
|
|
"policy_id must be 56 hex chars, got {}",
|
|
hex_str.len()
|
|
)));
|
|
}
|
|
let mut out = [0u8; 28];
|
|
for i in 0..28 {
|
|
out[i] = u8::from_str_radix(&hex_str[i * 2..i * 2 + 2], 16)
|
|
.map_err(|_| WalletError::Derivation(format!("invalid hex in policy_id: {hex_str}")))?;
|
|
}
|
|
Ok(pallas_crypto::hash::Hash::<28>::new(out))
|
|
}
|
|
|
|
fn parse_asset_name(hex_str: &str) -> Result<Vec<u8>, WalletError> {
|
|
if !hex_str.len().is_multiple_of(2) {
|
|
return Err(WalletError::Derivation(
|
|
"asset_name hex must have even length".into(),
|
|
));
|
|
}
|
|
if hex_str.len() > 64 {
|
|
return Err(WalletError::Derivation(format!(
|
|
"asset_name too long: {} hex chars (>64)",
|
|
hex_str.len()
|
|
)));
|
|
}
|
|
let mut out = Vec::with_capacity(hex_str.len() / 2);
|
|
for i in (0..hex_str.len()).step_by(2) {
|
|
out.push(u8::from_str_radix(&hex_str[i..i + 2], 16).map_err(|_| {
|
|
WalletError::Derivation(format!("invalid hex in asset_name: {hex_str}"))
|
|
})?);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Asset-aware UTXO selection.
|
|
///
|
|
/// Two-phase greedy:
|
|
/// 1. For each requested asset, pull UTXOs that contain it (largest
|
|
/// holding first) until coverage ≥ target.
|
|
/// 2. Then pull additional ADA-only UTXOs (largest first) until
|
|
/// `lovelace_acc ≥ target_lovelace + fee + min_change`.
|
|
///
|
|
/// Returns the selected UTXO set (preserves insertion order so
|
|
/// asset-bearing inputs sort before ADA-only ones — useful when
|
|
/// debugging asset balances).
|
|
fn select_utxos(
|
|
available: &[InputUtxo],
|
|
target_lovelace: u64,
|
|
target_assets: &std::collections::BTreeMap<String, u64>,
|
|
fee_estimate: u64,
|
|
min_change: u64,
|
|
) -> Result<Vec<InputUtxo>, WalletError> {
|
|
let mut selected: Vec<InputUtxo> = Vec::new();
|
|
let mut acc_lovelace: u64 = 0;
|
|
let mut acc_assets: std::collections::BTreeMap<String, u64> = Default::default();
|
|
|
|
let already_selected = |sel: &Vec<InputUtxo>, u: &InputUtxo| -> bool {
|
|
sel.iter()
|
|
.any(|s| s.tx_hash_hex == u.tx_hash_hex && s.output_index == u.output_index)
|
|
};
|
|
let absorb = |u: InputUtxo,
|
|
sel: &mut Vec<InputUtxo>,
|
|
ada: &mut u64,
|
|
ass: &mut std::collections::BTreeMap<String, u64>| {
|
|
*ada = ada.saturating_add(u.lovelace);
|
|
for (k, v) in &u.assets {
|
|
let entry = ass.entry(k.clone()).or_insert(0);
|
|
*entry = entry.saturating_add(*v);
|
|
}
|
|
sel.push(u);
|
|
};
|
|
|
|
// Phase 1: cover each required asset.
|
|
for (asset_key, target_qty) in target_assets {
|
|
while acc_assets.get(asset_key).copied().unwrap_or(0) < *target_qty {
|
|
let candidate = available
|
|
.iter()
|
|
.filter(|u| !already_selected(&selected, u))
|
|
.filter(|u| u.assets.contains_key(asset_key))
|
|
.max_by_key(|u| u.assets.get(asset_key).copied().unwrap_or(0));
|
|
match candidate {
|
|
Some(u) => absorb(u.clone(), &mut selected, &mut acc_lovelace, &mut acc_assets),
|
|
None => {
|
|
return Err(WalletError::Derivation(format!(
|
|
"insufficient asset {asset_key}: need {target_qty}, have {}",
|
|
acc_assets.get(asset_key).copied().unwrap_or(0)
|
|
)))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Phase 2: cover remaining lovelace need.
|
|
let need = target_lovelace
|
|
.checked_add(fee_estimate)
|
|
.and_then(|x| x.checked_add(min_change))
|
|
.ok_or_else(|| WalletError::Derivation("amount + fee + min_change overflows u64".into()))?;
|
|
|
|
while acc_lovelace < need {
|
|
let candidate = available
|
|
.iter()
|
|
.filter(|u| !already_selected(&selected, u))
|
|
.max_by_key(|u| u.lovelace);
|
|
match candidate {
|
|
Some(u) => absorb(u.clone(), &mut selected, &mut acc_lovelace, &mut acc_assets),
|
|
None => {
|
|
return Err(WalletError::Derivation(format!(
|
|
"insufficient funds: need {need} lovelace (target+fee+min_change), have {acc_lovelace}"
|
|
)))
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(selected)
|
|
}
|
|
|
|
fn parse_address(bech32: &str) -> Result<PallasAddress, WalletError> {
|
|
PallasAddress::from_bech32(bech32).map_err(|e| WalletError::Address(e.to_string()))
|
|
}
|
|
|
|
fn parse_tx_hash(hex_str: &str) -> Result<pallas_crypto::hash::Hash<32>, WalletError> {
|
|
let bytes = hex_decode_32(hex_str)?;
|
|
Ok(pallas_crypto::hash::Hash::<32>::new(bytes))
|
|
}
|
|
|
|
fn hex_decode_32(s: &str) -> Result<[u8; 32], WalletError> {
|
|
if s.len() != 64 {
|
|
return Err(WalletError::Derivation(format!(
|
|
"expected 64-char hex tx_hash, got {} chars",
|
|
s.len()
|
|
)));
|
|
}
|
|
let mut out = [0u8; 32];
|
|
for i in 0..32 {
|
|
out[i] = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16)
|
|
.map_err(|_| WalletError::Derivation(format!("invalid hex in tx_hash: {s}")))?;
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// 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 mut extended: [u8; 64] = xprv.extended_secret_key();
|
|
let secret = SecretKeyExtended::from_bytes(extended)
|
|
.map_err(|e| WalletError::Derivation(format!("invalid extended secret: {e}")));
|
|
extended.zeroize();
|
|
let secret = secret?;
|
|
Ok(PrivateKey::Extended(secret))
|
|
}
|
|
|
|
fn network_id_for(network: Network) -> u8 {
|
|
match network {
|
|
Network::Mainnet => 1,
|
|
Network::Preview | Network::Preprod => 0,
|
|
}
|
|
}
|
|
|
|
fn output_with_assets(
|
|
addr: &PallasAddress,
|
|
lovelace: u64,
|
|
assets: &std::collections::BTreeMap<String, u64>,
|
|
inline_datum_cbor: Option<&[u8]>,
|
|
reference_script: Option<ReferenceScriptSpec<'_>>,
|
|
) -> Result<Output, WalletError> {
|
|
let mut out = Output::new(addr.clone(), lovelace);
|
|
for (key, qty) in assets {
|
|
if *qty == 0 {
|
|
continue;
|
|
}
|
|
let (pol_hex, name_hex) = split_asset_key(key)?;
|
|
let policy = parse_policy_id(pol_hex)?;
|
|
let name = parse_asset_name(name_hex)?;
|
|
out = out
|
|
.add_asset(policy, name, *qty)
|
|
.map_err(|e| WalletError::Derivation(format!("output add_asset: {e}")))?;
|
|
}
|
|
// Optional inline datum for locking funds at a script address.
|
|
// Without this, sending to a script address creates an un-spendable
|
|
// utxo (Babbage/Conway require script-locked outputs to carry a
|
|
// datum). Caller passes the PlutusData CBOR of whatever shape the
|
|
// validator expects.
|
|
if let Some(datum) = inline_datum_cbor {
|
|
out = out.set_inline_datum(datum.to_vec());
|
|
}
|
|
// Optional reference-script attached to the output — equivalent of
|
|
// `cardano-cli ... --tx-out-reference-script-file ...`. Once deployed,
|
|
// downstream txs can witness the script via `read_only_input` instead
|
|
// of inline-witnessing the full CBOR. Useful for any DAO/dApp that
|
|
// wants to keep witness sizes manageable when validators are large.
|
|
if let Some(rs) = reference_script {
|
|
out = out.set_inline_script(rs.kind, rs.cbor.to_vec());
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn build_staging_with_fee(
|
|
inputs: &[InputUtxo],
|
|
to_addr: &PallasAddress,
|
|
to_lovelace: u64,
|
|
to_assets: &std::collections::BTreeMap<String, u64>,
|
|
to_inline_datum_cbor: Option<&[u8]>,
|
|
to_reference_script: Option<ReferenceScriptSpec<'_>>,
|
|
change_addr: &PallasAddress,
|
|
change_lovelace: u64,
|
|
change_assets: &std::collections::BTreeMap<String, u64>,
|
|
fee: u64,
|
|
network_id: u8,
|
|
) -> Result<StagingTransaction, WalletError> {
|
|
let mut staging = StagingTransaction::new();
|
|
for u in inputs {
|
|
let h = parse_tx_hash(&u.tx_hash_hex)?;
|
|
staging = staging.input(Input::new(h, u.output_index as u64));
|
|
}
|
|
staging = staging.output(output_with_assets(
|
|
to_addr,
|
|
to_lovelace,
|
|
to_assets,
|
|
to_inline_datum_cbor,
|
|
to_reference_script,
|
|
)?);
|
|
let nonzero_change_assets: std::collections::BTreeMap<String, u64> = change_assets
|
|
.iter()
|
|
.filter(|(_, q)| **q > 0)
|
|
.map(|(k, v)| (k.clone(), *v))
|
|
.collect();
|
|
if change_lovelace > 0 || !nonzero_change_assets.is_empty() {
|
|
// Change output never carries an inline datum or reference
|
|
// script — it goes back to the wallet, which has no validator
|
|
// to satisfy and no reason to publish a script there.
|
|
staging = staging.output(output_with_assets(
|
|
change_addr,
|
|
change_lovelace,
|
|
&nonzero_change_assets,
|
|
None,
|
|
None,
|
|
)?);
|
|
}
|
|
staging = staging.fee(fee).network_id(network_id);
|
|
Ok(staging)
|
|
}
|
|
|
|
/// One VKey witness adds: 32-byte vkey + 64-byte signature + CBOR
|
|
/// framing overhead. Empirically ~102 bytes; we round up to 128 for
|
|
/// safety so a single-witness payment never under-estimates fee.
|
|
const WITNESS_OVERHEAD_BYTES: u64 = 128;
|
|
|
|
/// Human-readable summary of an unsigned tx — meant for an LLM /
|
|
/// human reviewer to verify before signing or submitting. All
|
|
/// amounts in lovelace.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct PaymentSummary {
|
|
/// Predicted transaction hash. The signed CBOR will hash to
|
|
/// the same value (signature is on the body hash, not over the
|
|
/// whole CBOR). Use this to confirm the signed CBOR returned
|
|
/// from a cold-signer matches the body that was reviewed.
|
|
pub tx_hash: String,
|
|
pub network: Network,
|
|
pub from_address: String,
|
|
pub to_address: String,
|
|
pub send_lovelace: u64,
|
|
pub fee_lovelace: u64,
|
|
pub change_lovelace: u64,
|
|
pub num_inputs: usize,
|
|
/// Native assets sent to the recipient.
|
|
#[serde(default)]
|
|
pub send_assets: Vec<AssetSpec>,
|
|
/// Native assets returning to the change address.
|
|
#[serde(default)]
|
|
pub change_assets: Vec<AssetSpec>,
|
|
}
|
|
|
|
/// Output of `build_unsigned_payment` — both the CBOR bytes (for
|
|
/// the cold-signer) and a human-readable summary (for the
|
|
/// reviewer).
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct UnsignedPayment {
|
|
/// Hex-encoded unsigned transaction CBOR.
|
|
pub cbor_hex: String,
|
|
pub summary: PaymentSummary,
|
|
}
|
|
|
|
fn build_unsigned_bytes(staging: StagingTransaction) -> Result<Vec<u8>, WalletError> {
|
|
let built = staging
|
|
.build_conway_raw()
|
|
.map_err(|e| WalletError::Derivation(format!("conway build: {e}")))?;
|
|
Ok(built.tx_bytes.0)
|
|
}
|
|
|
|
/// Internal helper — runs the two-pass fee refinement and returns
|
|
/// the final `BuiltTransaction` plus a `PaymentSummary` describing
|
|
/// the body. Handles both ADA-only and multi-asset payments; pass
|
|
/// `&[]` for `assets_to_send` to keep it ADA-only.
|
|
///
|
|
/// `to_inline_datum_cbor`, when `Some`, attaches the bytes as an
|
|
/// inline datum on the recipient output — needed to lock funds at a
|
|
/// script address with a datum the validator can read. The change
|
|
/// output never gets a datum (it goes back to the wallet, no
|
|
/// validator to satisfy).
|
|
///
|
|
/// `to_reference_script`, when `Some`, attaches the script bytes as
|
|
/// a reference-script on the recipient output (Babbage/Conway era
|
|
/// `--tx-out-reference-script-file`). Used to deploy a Plutus
|
|
/// validator/policy as a reusable on-chain reference. Pairs naturally
|
|
/// with sending to the wallet's own address — the wallet then "owns"
|
|
/// (spends from) the ref-script UTxO any time it wants to retire it.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn prepare_payment(
|
|
network: Network,
|
|
available_utxos: &[InputUtxo],
|
|
change_address_bech32: &str,
|
|
to_address_bech32: &str,
|
|
lovelace: u64,
|
|
assets_to_send: &[AssetSpec],
|
|
to_inline_datum_cbor: Option<&[u8]>,
|
|
to_reference_script: Option<ReferenceScriptSpec<'_>>,
|
|
params: &ProtocolParams,
|
|
) -> Result<(BuiltTransaction, PaymentSummary), WalletError> {
|
|
let to_addr = parse_address(to_address_bech32)?;
|
|
let change_addr = parse_address(change_address_bech32)?;
|
|
let network_id = network_id_for(network);
|
|
|
|
// Aggregate target assets by canonical key, so duplicates in the
|
|
// caller's input list sum together rather than confusing
|
|
// selection.
|
|
let mut target_assets: std::collections::BTreeMap<String, u64> = Default::default();
|
|
for spec in assets_to_send {
|
|
let entry = target_assets.entry(spec.key()).or_insert(0);
|
|
*entry = entry.saturating_add(spec.quantity);
|
|
}
|
|
|
|
// Pass 1: pick inputs assuming a generous placeholder fee, then
|
|
// build *unsigned* to measure size. We add WITNESS_OVERHEAD_BYTES
|
|
// to account for the witness this tx will carry once signed.
|
|
// fee_pass1 is the selector's safety floor — must be ≥ the real
|
|
// fee that pass 2 computes from actual signed-tx size, otherwise
|
|
// "insufficient funds for fee" downstream. Real fees on this
|
|
// wallet's typical txs:
|
|
// 1-in 1-out ada-only send : ~166 k lovelace
|
|
// 1-in 2-out (with change) : ~178 k
|
|
// CIP-25 mint w/ metadata : ~210 k
|
|
// Setting 200_000 covers basic-send cases (which need to drain
|
|
// to fee on small balances — see ada_only_send_can_drain_to_fee).
|
|
// Mint paths typically have more lovelace headroom and won't
|
|
// hit the pass1 floor; if a mint does run tight, the downstream
|
|
// "insufficient funds for fee" error is informative.
|
|
let fee_pass1: u64 = 200_000;
|
|
// ada-only sends fold sub-min change into fee on the happy path
|
|
// (the `Some(c)` ADA-only arm below), so the selector shouldn't
|
|
// insist on having `min_utxo_lovelace` worth of room for change.
|
|
// Pass 0 when there are no asset leftovers; assets-bearing sends
|
|
// still need real change to route the leftover policy IDs, so
|
|
// keep min_utxo_lovelace there.
|
|
let min_change_required = if target_assets.is_empty() {
|
|
0
|
|
} else {
|
|
params.min_utxo_lovelace
|
|
};
|
|
let inputs = select_utxos(
|
|
available_utxos,
|
|
lovelace,
|
|
&target_assets,
|
|
fee_pass1,
|
|
min_change_required,
|
|
)?;
|
|
let total_in_lovelace: u64 = inputs.iter().map(|u| u.lovelace).sum();
|
|
|
|
// Sum input assets across selected UTXOs.
|
|
let mut total_in_assets: std::collections::BTreeMap<String, u64> = Default::default();
|
|
for u in &inputs {
|
|
for (k, v) in &u.assets {
|
|
let entry = total_in_assets.entry(k.clone()).or_insert(0);
|
|
*entry = entry.saturating_add(*v);
|
|
}
|
|
}
|
|
|
|
// Change-side assets: input assets minus what's being sent.
|
|
let mut change_assets: std::collections::BTreeMap<String, u64> = Default::default();
|
|
for (k, v) in &total_in_assets {
|
|
let sent = target_assets.get(k).copied().unwrap_or(0);
|
|
let leftover = v.saturating_sub(sent);
|
|
if leftover > 0 {
|
|
change_assets.insert(k.clone(), leftover);
|
|
}
|
|
}
|
|
|
|
let change_pass1 = total_in_lovelace
|
|
.checked_sub(lovelace)
|
|
.and_then(|x| x.checked_sub(fee_pass1))
|
|
.ok_or_else(|| {
|
|
WalletError::Derivation("pass1: inputs do not cover lovelace + fee".into())
|
|
})?;
|
|
|
|
let staging1 = build_staging_with_fee(
|
|
&inputs,
|
|
&to_addr,
|
|
lovelace,
|
|
&target_assets,
|
|
to_inline_datum_cbor,
|
|
to_reference_script,
|
|
&change_addr,
|
|
change_pass1,
|
|
&change_assets,
|
|
fee_pass1,
|
|
network_id,
|
|
)?;
|
|
let unsigned_bytes = build_unsigned_bytes(staging1)?;
|
|
let estimated_signed_size = (unsigned_bytes.len() as u64) + WITNESS_OVERHEAD_BYTES;
|
|
let real_fee = params.min_fee_for_size(estimated_signed_size);
|
|
|
|
// If the wallet has leftover assets, the change output must
|
|
// exist (assets can't ride only on the fee). Force a min-utxo
|
|
// worth of lovelace into change in that case.
|
|
let change_must_exist = !change_assets.is_empty();
|
|
|
|
// checked_add on 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 => {
|
|
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={}",
|
|
params.min_utxo_lovelace
|
|
)));
|
|
}
|
|
(real_fee, c)
|
|
}
|
|
// ADA-only path with sub-min change — fold into fee.
|
|
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}"
|
|
)))
|
|
}
|
|
};
|
|
|
|
// Pass 2: build with real fee + final change.
|
|
let staging2 = build_staging_with_fee(
|
|
&inputs,
|
|
&to_addr,
|
|
lovelace,
|
|
&target_assets,
|
|
to_inline_datum_cbor,
|
|
to_reference_script,
|
|
&change_addr,
|
|
final_change,
|
|
&change_assets,
|
|
final_fee,
|
|
network_id,
|
|
)?;
|
|
let built = staging2
|
|
.build_conway_raw()
|
|
.map_err(|e| WalletError::Derivation(format!("conway build: {e}")))?;
|
|
|
|
// Re-shape the asset maps back into Vec<AssetSpec> for the
|
|
// summary — easier for callers to display than a BTreeMap.
|
|
// Logic-bug paths (we built the key) propagate as typed errors
|
|
// rather than panicking the process.
|
|
let send_assets_vec: Vec<AssetSpec> = target_assets
|
|
.iter()
|
|
.map(|(k, v)| {
|
|
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::<Result<_, _>>()?;
|
|
let change_assets_vec: Vec<AssetSpec> = change_assets
|
|
.iter()
|
|
.map(|(k, v)| {
|
|
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::<Result<_, _>>()?;
|
|
|
|
let summary = PaymentSummary {
|
|
tx_hash: hex_encode(&built.tx_hash.0),
|
|
network,
|
|
from_address: change_address_bech32.to_string(),
|
|
to_address: to_address_bech32.to_string(),
|
|
send_lovelace: lovelace,
|
|
fee_lovelace: final_fee,
|
|
change_lovelace: final_change,
|
|
num_inputs: inputs.len(),
|
|
send_assets: send_assets_vec,
|
|
change_assets: change_assets_vec,
|
|
};
|
|
|
|
Ok((built, summary))
|
|
}
|
|
|
|
fn hex_encode(bytes: &[u8]) -> String {
|
|
let mut s = String::with_capacity(bytes.len() * 2);
|
|
for b in bytes {
|
|
s.push_str(&format!("{:02x}", b));
|
|
}
|
|
s
|
|
}
|
|
|
|
/// Hex-decode a string into a Vec — used by the cold-sign flow to
|
|
/// turn the externally-signed tx hex back into bytes for submission.
|
|
pub fn hex_decode(s: &str) -> Result<Vec<u8>, WalletError> {
|
|
if !s.len().is_multiple_of(2) {
|
|
return Err(WalletError::Derivation("hex string has odd length".into()));
|
|
}
|
|
let mut out = Vec::with_capacity(s.len() / 2);
|
|
for i in (0..s.len()).step_by(2) {
|
|
let byte = u8::from_str_radix(&s[i..i + 2], 16)
|
|
.map_err(|_| WalletError::Derivation(format!("invalid hex char at {i}")))?;
|
|
out.push(byte);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Build + sign a Conway-era ADA-only payment. Convenience wrapper
|
|
/// around [`build_signed_payment_with_assets`] with no native
|
|
/// assets. Returns the signed CBOR bytes ready for submission via
|
|
/// `ChainBackend::submit_tx`.
|
|
pub fn build_signed_payment(
|
|
payment_key: &PaymentKey,
|
|
network: Network,
|
|
available_utxos: &[InputUtxo],
|
|
change_address_bech32: &str,
|
|
to_address_bech32: &str,
|
|
lovelace: u64,
|
|
params: &ProtocolParams,
|
|
) -> Result<Vec<u8>, WalletError> {
|
|
build_signed_payment_with_assets(
|
|
payment_key,
|
|
network,
|
|
available_utxos,
|
|
change_address_bech32,
|
|
to_address_bech32,
|
|
lovelace,
|
|
&[],
|
|
None,
|
|
params,
|
|
)
|
|
}
|
|
|
|
/// Build + sign a Conway-era payment that may include native assets.
|
|
/// Returns the signed CBOR bytes ready for `ChainBackend::submit_tx`.
|
|
///
|
|
/// `to_inline_datum_cbor`, when `Some`, attaches the bytes as an
|
|
/// inline datum on the recipient output — needed to lock funds at
|
|
/// a script address with a datum the validator can read. `None`
|
|
/// for normal sends to wallet addresses.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn build_signed_payment_with_assets(
|
|
payment_key: &PaymentKey,
|
|
network: Network,
|
|
available_utxos: &[InputUtxo],
|
|
change_address_bech32: &str,
|
|
to_address_bech32: &str,
|
|
lovelace: u64,
|
|
assets_to_send: &[AssetSpec],
|
|
to_inline_datum_cbor: Option<&[u8]>,
|
|
params: &ProtocolParams,
|
|
) -> Result<Vec<u8>, WalletError> {
|
|
build_signed_payment_extras(
|
|
payment_key,
|
|
network,
|
|
available_utxos,
|
|
change_address_bech32,
|
|
to_address_bech32,
|
|
lovelace,
|
|
assets_to_send,
|
|
to_inline_datum_cbor,
|
|
None,
|
|
params,
|
|
)
|
|
}
|
|
|
|
/// Build + sign a Conway-era payment with the full output extras —
|
|
/// ADA + native assets + optional inline datum + optional reference
|
|
/// script. Public superset of `build_signed_payment_with_assets`.
|
|
///
|
|
/// `to_reference_script`: when `Some`, attaches the script CBOR as a
|
|
/// reference-script on the recipient output (Babbage/Conway era).
|
|
/// Used to deploy a Plutus validator/policy as a reusable on-chain
|
|
/// reference. Pair with sending to the wallet's own address so the
|
|
/// wallet retains the ability to retire the deployment later.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn build_signed_payment_extras(
|
|
payment_key: &PaymentKey,
|
|
network: Network,
|
|
available_utxos: &[InputUtxo],
|
|
change_address_bech32: &str,
|
|
to_address_bech32: &str,
|
|
lovelace: u64,
|
|
assets_to_send: &[AssetSpec],
|
|
to_inline_datum_cbor: Option<&[u8]>,
|
|
to_reference_script: Option<ReferenceScriptSpec<'_>>,
|
|
params: &ProtocolParams,
|
|
) -> Result<Vec<u8>, WalletError> {
|
|
let private = payment_key_to_private(payment_key)?;
|
|
let (built, _summary) = prepare_payment(
|
|
network,
|
|
available_utxos,
|
|
change_address_bech32,
|
|
to_address_bech32,
|
|
lovelace,
|
|
assets_to_send,
|
|
to_inline_datum_cbor,
|
|
to_reference_script,
|
|
params,
|
|
)?;
|
|
let signed = built
|
|
.sign(private)
|
|
.map_err(|e| WalletError::Derivation(format!("sign: {e}")))?;
|
|
Ok(signed.tx_bytes.0)
|
|
}
|
|
|
|
/// Build an ADA-only payment without signing. Convenience wrapper
|
|
/// around [`build_unsigned_payment_with_assets`].
|
|
pub fn build_unsigned_payment(
|
|
network: Network,
|
|
available_utxos: &[InputUtxo],
|
|
change_address_bech32: &str,
|
|
to_address_bech32: &str,
|
|
lovelace: u64,
|
|
params: &ProtocolParams,
|
|
) -> Result<UnsignedPayment, WalletError> {
|
|
build_unsigned_payment_with_assets(
|
|
network,
|
|
available_utxos,
|
|
change_address_bech32,
|
|
to_address_bech32,
|
|
lovelace,
|
|
&[],
|
|
None,
|
|
params,
|
|
)
|
|
}
|
|
|
|
/// Build a Conway-era payment (ADA + optional native assets) without
|
|
/// signing. Returns the unsigned CBOR + a `PaymentSummary` for human
|
|
/// review. Caller signs externally and submits via
|
|
/// `ChainBackend::submit_tx`.
|
|
///
|
|
/// `to_inline_datum_cbor` — see [`build_signed_payment_with_assets`].
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn build_unsigned_payment_with_assets(
|
|
network: Network,
|
|
available_utxos: &[InputUtxo],
|
|
change_address_bech32: &str,
|
|
to_address_bech32: &str,
|
|
lovelace: u64,
|
|
assets_to_send: &[AssetSpec],
|
|
to_inline_datum_cbor: Option<&[u8]>,
|
|
params: &ProtocolParams,
|
|
) -> Result<UnsignedPayment, WalletError> {
|
|
build_unsigned_payment_extras(
|
|
network,
|
|
available_utxos,
|
|
change_address_bech32,
|
|
to_address_bech32,
|
|
lovelace,
|
|
assets_to_send,
|
|
to_inline_datum_cbor,
|
|
None,
|
|
params,
|
|
)
|
|
}
|
|
|
|
/// Build a Conway-era payment with the full output extras (ADA +
|
|
/// native assets + optional inline datum + optional reference script)
|
|
/// without signing. Returns unsigned CBOR + `PaymentSummary`. Caller
|
|
/// signs + submits via the cold-sign path.
|
|
///
|
|
/// See [`build_signed_payment_extras`] for the signed variant.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn build_unsigned_payment_extras(
|
|
network: Network,
|
|
available_utxos: &[InputUtxo],
|
|
change_address_bech32: &str,
|
|
to_address_bech32: &str,
|
|
lovelace: u64,
|
|
assets_to_send: &[AssetSpec],
|
|
to_inline_datum_cbor: Option<&[u8]>,
|
|
to_reference_script: Option<ReferenceScriptSpec<'_>>,
|
|
params: &ProtocolParams,
|
|
) -> Result<UnsignedPayment, WalletError> {
|
|
let (built, summary) = prepare_payment(
|
|
network,
|
|
available_utxos,
|
|
change_address_bech32,
|
|
to_address_bech32,
|
|
lovelace,
|
|
assets_to_send,
|
|
to_inline_datum_cbor,
|
|
to_reference_script,
|
|
params,
|
|
)?;
|
|
Ok(UnsignedPayment {
|
|
cbor_hex: hex_encode(&built.tx_bytes.0),
|
|
summary,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::Mnemonic;
|
|
|
|
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 payment_from_canonical() -> PaymentKey {
|
|
let root = Mnemonic::from_phrase(ABANDON_ART)
|
|
.unwrap()
|
|
.into_root_key()
|
|
.unwrap();
|
|
crate::derive::derive_payment_key(&root, 0, 0)
|
|
}
|
|
|
|
fn change_address(network: Network) -> String {
|
|
let root = Mnemonic::from_phrase(ABANDON_ART)
|
|
.unwrap()
|
|
.into_root_key()
|
|
.unwrap();
|
|
crate::derive_base_address(&root, network, 0, 0).unwrap()
|
|
}
|
|
|
|
/// A different valid preprod address — derived from the same
|
|
/// canonical mnemonic at index 1 (vs index 0 for the change
|
|
/// address). Spending to a derived-but-different address is
|
|
/// realistic enough for an end-to-end tx-build test.
|
|
fn to_address_preprod() -> String {
|
|
let root = Mnemonic::from_phrase(ABANDON_ART)
|
|
.unwrap()
|
|
.into_root_key()
|
|
.unwrap();
|
|
crate::derive_base_address(&root, Network::Preprod, 0, 1).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn min_fee_formula_matches_known_values() {
|
|
let p = ProtocolParams::default();
|
|
// ~250-byte tx ≈ 0.166 ADA fee — typical for a 1-in-2-out payment.
|
|
let fee_250 = p.min_fee_for_size(250);
|
|
assert_eq!(fee_250, 44 * 250 + 155_381);
|
|
// Smaller tx pays less.
|
|
assert!(p.min_fee_for_size(100) < fee_250);
|
|
}
|
|
|
|
fn ada_utxo(tx_byte: u8, lovelace: u64) -> InputUtxo {
|
|
InputUtxo {
|
|
tx_hash_hex: format!("{tx_byte:02x}").repeat(32),
|
|
output_index: 0,
|
|
lovelace,
|
|
assets: Default::default(),
|
|
}
|
|
}
|
|
|
|
fn empty_assets() -> std::collections::BTreeMap<String, u64> {
|
|
Default::default()
|
|
}
|
|
|
|
#[test]
|
|
fn select_utxos_greedy_returns_largest_first() {
|
|
let available = vec![
|
|
ada_utxo(0x11, 5_000_000),
|
|
ada_utxo(0x22, 50_000_000),
|
|
ada_utxo(0x33, 1_000_000),
|
|
];
|
|
let chosen =
|
|
select_utxos(&available, 10_000_000, &empty_assets(), 500_000, 1_000_000).unwrap();
|
|
assert_eq!(chosen.len(), 1);
|
|
assert_eq!(chosen[0].lovelace, 50_000_000);
|
|
}
|
|
|
|
#[test]
|
|
fn select_utxos_chains_when_one_isnt_enough() {
|
|
let available = vec![ada_utxo(0x11, 5_000_000), ada_utxo(0x22, 8_000_000)];
|
|
let chosen =
|
|
select_utxos(&available, 10_000_000, &empty_assets(), 500_000, 1_000_000).unwrap();
|
|
assert_eq!(chosen.len(), 2);
|
|
assert_eq!(chosen[0].lovelace, 8_000_000);
|
|
}
|
|
|
|
#[test]
|
|
fn select_utxos_errors_when_insufficient() {
|
|
let available = vec![ada_utxo(0x11, 1_000_000)];
|
|
let err =
|
|
select_utxos(&available, 10_000_000, &empty_assets(), 500_000, 1_000_000).unwrap_err();
|
|
match err {
|
|
WalletError::Derivation(msg) => assert!(msg.contains("insufficient funds")),
|
|
other => panic!("expected Derivation, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn select_utxos_picks_asset_bearing_first() {
|
|
let policy = "ee".repeat(28);
|
|
let asset = format!("{policy}{}", "deadbeef");
|
|
let mut assets = std::collections::BTreeMap::new();
|
|
assets.insert(asset.clone(), 100u64);
|
|
|
|
let mut with_asset = ada_utxo(0xaa, 2_000_000);
|
|
with_asset.assets.insert(asset.clone(), 100);
|
|
|
|
let available = vec![ada_utxo(0xbb, 50_000_000), with_asset];
|
|
let mut target_assets = std::collections::BTreeMap::new();
|
|
target_assets.insert(asset.clone(), 50);
|
|
let chosen =
|
|
select_utxos(&available, 10_000_000, &target_assets, 500_000, 1_000_000).unwrap();
|
|
// First selected must be the asset-bearing UTXO.
|
|
assert_eq!(chosen[0].lovelace, 2_000_000);
|
|
// Then an ADA UTXO joins to cover the lovelace shortfall.
|
|
assert!(chosen.iter().any(|u| u.lovelace == 50_000_000));
|
|
}
|
|
|
|
#[test]
|
|
fn select_utxos_errors_on_missing_asset() {
|
|
let policy = "ff".repeat(28);
|
|
let asset = format!("{policy}{}", "abcd");
|
|
let mut target_assets = std::collections::BTreeMap::new();
|
|
target_assets.insert(asset, 1);
|
|
let available = vec![ada_utxo(0xaa, 50_000_000)];
|
|
let err =
|
|
select_utxos(&available, 10_000_000, &target_assets, 500_000, 1_000_000).unwrap_err();
|
|
match err {
|
|
WalletError::Derivation(msg) => assert!(msg.contains("insufficient asset")),
|
|
other => panic!("expected Derivation, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_policy_id_validates_length() {
|
|
assert!(parse_policy_id("ab").is_err());
|
|
assert!(parse_policy_id(&"ee".repeat(28)).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_asset_name_validates_length() {
|
|
assert!(parse_asset_name(&"ab".repeat(33)).is_err()); // > 32 bytes
|
|
assert!(parse_asset_name("deadbeef").is_ok()); // 4 bytes
|
|
assert!(parse_asset_name("").is_ok()); // 0 bytes — valid (ADA-symbol-like)
|
|
}
|
|
|
|
#[test]
|
|
fn split_asset_key_at_56_chars() {
|
|
let policy = "ab".repeat(28);
|
|
let key = format!("{policy}{}", "deadbeef");
|
|
let (p, n) = split_asset_key(&key).unwrap();
|
|
assert_eq!(p, policy);
|
|
assert_eq!(n, "deadbeef");
|
|
}
|
|
|
|
#[test]
|
|
fn hex_decode_validates_length() {
|
|
assert!(hex_decode_32("ab").is_err());
|
|
assert!(hex_decode_32(&"00".repeat(31)).is_err());
|
|
assert!(hex_decode_32(&"00".repeat(32)).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn payment_key_converts_to_pallas_private_key() {
|
|
let pk = payment_from_canonical();
|
|
let private = payment_key_to_private(&pk).unwrap();
|
|
// Round-trip: pubkey from PaymentKey vs from PrivateKey
|
|
// should match (both derive from the same XPrv).
|
|
let derived_pubkey = private.public_key();
|
|
let pubkey_bytes: &[u8] = derived_pubkey.as_ref();
|
|
assert_eq!(pubkey_bytes.len(), 32);
|
|
}
|
|
|
|
fn single_ada_utxo(lovelace: u64) -> InputUtxo {
|
|
InputUtxo {
|
|
tx_hash_hex: "deadbeef".repeat(8),
|
|
output_index: 0,
|
|
lovelace,
|
|
assets: Default::default(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn build_signed_payment_produces_cbor() {
|
|
let payment = payment_from_canonical();
|
|
let change = change_address(Network::Preprod);
|
|
let utxos = vec![single_ada_utxo(100_000_000)];
|
|
let cbor = build_signed_payment(
|
|
&payment,
|
|
Network::Preprod,
|
|
&utxos,
|
|
&change,
|
|
&to_address_preprod(),
|
|
10_000_000,
|
|
&ProtocolParams::default(),
|
|
)
|
|
.expect("payment builds + signs");
|
|
// Conway-era signed tx is non-trivial — sanity check it's
|
|
// well over the fee constant.
|
|
assert!(cbor.len() > 100, "cbor too short: {} bytes", cbor.len());
|
|
// CBOR major type 4 (array) — a Cardano tx is `[tx_body,
|
|
// witness_set, valid, auxiliary_data]`. First byte should
|
|
// start with 0x80..0x9f range for an array.
|
|
assert!(
|
|
(cbor[0] & 0xe0) == 0x80,
|
|
"first CBOR byte not array-typed: 0x{:02x}",
|
|
cbor[0]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn hex_round_trip() {
|
|
let bytes = vec![0xde, 0xad, 0xbe, 0xef, 0x00, 0xff, 0x42];
|
|
let encoded = hex_encode(&bytes);
|
|
assert_eq!(encoded, "deadbeef00ff42");
|
|
let decoded = hex_decode(&encoded).unwrap();
|
|
assert_eq!(decoded, bytes);
|
|
}
|
|
|
|
#[test]
|
|
fn hex_decode_rejects_garbage() {
|
|
assert!(hex_decode("zz").is_err());
|
|
assert!(hex_decode("abc").is_err()); // odd length
|
|
}
|
|
|
|
#[test]
|
|
fn unsigned_payment_summary_is_populated() {
|
|
let change = change_address(Network::Preprod);
|
|
let utxos = vec![single_ada_utxo(100_000_000)];
|
|
let result = build_unsigned_payment(
|
|
Network::Preprod,
|
|
&utxos,
|
|
&change,
|
|
&to_address_preprod(),
|
|
10_000_000,
|
|
&ProtocolParams::default(),
|
|
)
|
|
.expect("unsigned builds");
|
|
let s = &result.summary;
|
|
assert_eq!(s.send_lovelace, 10_000_000);
|
|
assert_eq!(s.num_inputs, 1);
|
|
assert!(s.fee_lovelace > 0);
|
|
// Total in (100M) - send (10M) - fee = change.
|
|
assert_eq!(
|
|
s.change_lovelace,
|
|
100_000_000 - s.send_lovelace - s.fee_lovelace
|
|
);
|
|
// 32-byte hash → 64 hex chars.
|
|
assert_eq!(s.tx_hash.len(), 64);
|
|
// Round-trip the unsigned CBOR.
|
|
let decoded = hex_decode(&result.cbor_hex).unwrap();
|
|
assert!(decoded.len() > 50);
|
|
}
|
|
|
|
#[test]
|
|
fn unsigned_and_signed_have_same_body_hash() {
|
|
let payment = payment_from_canonical();
|
|
let change = change_address(Network::Preprod);
|
|
let utxos = vec![single_ada_utxo(100_000_000)];
|
|
let unsigned = build_unsigned_payment(
|
|
Network::Preprod,
|
|
&utxos,
|
|
&change,
|
|
&to_address_preprod(),
|
|
10_000_000,
|
|
&ProtocolParams::default(),
|
|
)
|
|
.unwrap();
|
|
let _signed = build_signed_payment(
|
|
&payment,
|
|
Network::Preprod,
|
|
&utxos,
|
|
&change,
|
|
&to_address_preprod(),
|
|
10_000_000,
|
|
&ProtocolParams::default(),
|
|
)
|
|
.unwrap();
|
|
// The unsigned summary.tx_hash should reflect the canonical
|
|
// body hash. Signing only adds witnesses; body is unchanged.
|
|
// Sanity: hash isn't all zeros.
|
|
assert_ne!(unsigned.summary.tx_hash, "0".repeat(64));
|
|
}
|
|
|
|
#[test]
|
|
fn build_signed_payment_with_assets_produces_cbor() {
|
|
let payment = payment_from_canonical();
|
|
let change = change_address(Network::Preprod);
|
|
let policy = "ee".repeat(28);
|
|
let asset_name = "deadbeef";
|
|
let asset_key = format!("{policy}{asset_name}");
|
|
|
|
let mut utxo_with_asset = single_ada_utxo(50_000_000);
|
|
utxo_with_asset.assets.insert(asset_key.clone(), 500);
|
|
// Need a second UTXO with a distinct tx_hash for fee/change ADA.
|
|
let utxo_for_fee = InputUtxo {
|
|
tx_hash_hex: "ff".repeat(32),
|
|
output_index: 1,
|
|
lovelace: 50_000_000,
|
|
assets: Default::default(),
|
|
};
|
|
|
|
let cbor = build_signed_payment_with_assets(
|
|
&payment,
|
|
Network::Preprod,
|
|
&[utxo_with_asset, utxo_for_fee],
|
|
&change,
|
|
&to_address_preprod(),
|
|
10_000_000,
|
|
&[AssetSpec {
|
|
policy_id_hex: policy,
|
|
asset_name_hex: asset_name.to_string(),
|
|
quantity: 100,
|
|
}],
|
|
None,
|
|
&ProtocolParams::default(),
|
|
)
|
|
.expect("multi-asset payment builds + signs");
|
|
// Multi-asset tx is meaningfully larger than ADA-only.
|
|
assert!(
|
|
cbor.len() > 200,
|
|
"cbor unexpectedly short: {} bytes",
|
|
cbor.len()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unsigned_with_assets_summary_includes_change_assets() {
|
|
let change = change_address(Network::Preprod);
|
|
let policy = "ee".repeat(28);
|
|
let asset_name = "deadbeef";
|
|
let asset_key = format!("{policy}{asset_name}");
|
|
|
|
let mut utxo = single_ada_utxo(100_000_000);
|
|
utxo.assets.insert(asset_key, 500);
|
|
|
|
let result = build_unsigned_payment_with_assets(
|
|
Network::Preprod,
|
|
&[utxo],
|
|
&change,
|
|
&to_address_preprod(),
|
|
10_000_000,
|
|
&[AssetSpec {
|
|
policy_id_hex: policy.clone(),
|
|
asset_name_hex: asset_name.to_string(),
|
|
quantity: 100,
|
|
}],
|
|
None,
|
|
&ProtocolParams::default(),
|
|
)
|
|
.unwrap();
|
|
// Sent 100 of 500 → change must hold 400.
|
|
assert_eq!(result.summary.send_assets.len(), 1);
|
|
assert_eq!(result.summary.send_assets[0].quantity, 100);
|
|
assert_eq!(result.summary.change_assets.len(), 1);
|
|
assert_eq!(result.summary.change_assets[0].quantity, 400);
|
|
assert_eq!(result.summary.change_assets[0].policy_id_hex, policy);
|
|
}
|
|
|
|
/// Regression: a wallet_send with `to_inline_datum_cbor` produces
|
|
/// an output carrying that datum. Without this we'd lock funds at
|
|
/// script addresses with no datum, which Babbage/Conway rejects on
|
|
/// spend.
|
|
#[test]
|
|
fn lock_with_inline_datum_attaches_datum_to_output() {
|
|
use pallas_primitives::Fragment;
|
|
let payment = payment_from_canonical();
|
|
let change = change_address(Network::Preprod);
|
|
let utxos = vec![single_ada_utxo(100_000_000)];
|
|
// Plutus Constr 0 [] = CBOR `d87980` — same shape we use as a
|
|
// unit redeemer for always-succeeds.
|
|
let datum = vec![0xd8, 0x79, 0x80];
|
|
let cbor = build_signed_payment_with_assets(
|
|
&payment,
|
|
Network::Preprod,
|
|
&utxos,
|
|
&change,
|
|
&to_address_preprod(),
|
|
10_000_000,
|
|
&[],
|
|
Some(&datum),
|
|
&ProtocolParams::default(),
|
|
)
|
|
.expect("lock with inline datum builds + signs");
|
|
let tx = pallas_primitives::conway::Tx::decode_fragment(&cbor)
|
|
.expect("decode lock-with-datum tx cbor");
|
|
// First output is the recipient — must carry an inline datum.
|
|
let outs = tx.transaction_body.outputs.to_vec();
|
|
assert!(!outs.is_empty(), "expected ≥1 output");
|
|
match &outs[0] {
|
|
pallas_primitives::conway::PseudoTransactionOutput::PostAlonzo(o) => {
|
|
let datum_present = o.datum_option.is_some();
|
|
assert!(datum_present, "first output must carry inline datum");
|
|
}
|
|
_ => panic!("expected PostAlonzo output shape"),
|
|
}
|
|
}
|
|
|
|
/// Regression: ada-only sends should be allowed to drain a wallet
|
|
/// down to "all of input - fee" without the selector reserving
|
|
/// min_utxo for a change output that ends up folded into the fee
|
|
/// anyway.
|
|
#[test]
|
|
fn ada_only_send_can_drain_to_fee() {
|
|
let payment = payment_from_canonical();
|
|
let change = change_address(Network::Preprod);
|
|
let utxos = vec![single_ada_utxo(2_000_000)];
|
|
// 1.8 ADA out of 2 ADA total. Change after fee will be sub-
|
|
// min-utxo, but the ada-only fold-into-fee path absorbs it.
|
|
let cbor = build_signed_payment(
|
|
&payment,
|
|
Network::Preprod,
|
|
&utxos,
|
|
&change,
|
|
&to_address_preprod(),
|
|
1_800_000,
|
|
&ProtocolParams::default(),
|
|
)
|
|
.expect("ada-only drain builds when change folds into fee");
|
|
assert!(cbor.len() > 100);
|
|
}
|
|
|
|
#[test]
|
|
fn build_signed_payment_fails_without_funds() {
|
|
let payment = payment_from_canonical();
|
|
let change = change_address(Network::Preprod);
|
|
let utxos = vec![single_ada_utxo(5_000_000)];
|
|
let err = build_signed_payment(
|
|
&payment,
|
|
Network::Preprod,
|
|
&utxos,
|
|
&change,
|
|
&to_address_preprod(),
|
|
10_000_000,
|
|
&ProtocolParams::default(),
|
|
)
|
|
.expect_err("expected insufficient-funds error");
|
|
match err {
|
|
WalletError::Derivation(msg) => assert!(msg.contains("insufficient funds")),
|
|
other => panic!("expected Derivation, got {other:?}"),
|
|
}
|
|
}
|
|
}
|