diff --git a/cli/Cargo.toml b/cli/Cargo.toml index a6b5e6b..86eb907 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -8,6 +8,7 @@ bech32 = "0.11.0" blockfrost = "1.0.1" blockfrost-openapi = "0.0.3" clap = "4.5.17" +color-print = "0.3.6" hex = "0.4.3" indoc = "2.0.5" pallas-addresses = "0.30.2" diff --git a/cli/src/cardano.rs b/cli/src/cardano.rs index 2bbfc11..6ec0e6e 100644 --- a/cli/src/cardano.rs +++ b/cli/src/cardano.rs @@ -11,6 +11,7 @@ use std::{collections::BTreeMap, env}; pub struct Cardano { api: BlockfrostAPI, + client: reqwest::Client, network: Network, network_prefix: String, project_id: String, @@ -43,6 +44,7 @@ impl Cardano { let api = BlockfrostAPI::new(project_id.as_str(), Default::default()); Cardano { api, + client: reqwest::Client::new(), network: if project_id.starts_with(MAINNET_PREFIX) { Network::Mainnet } else { @@ -140,36 +142,39 @@ impl Cardano { }) .collect::>(); - let client = reqwest::Client::new(); - let mut txs: Vec = vec![]; for tx_hash in history { - // NOTE: Not part of the Rust SDK somehow... - let response = client - .get(&format!( - "https://cardano-{}.blockfrost.io/api/v0/txs/{}/cbor", - self.network_prefix, tx_hash - )) - .header("Accept", "application/json") - .header("project_id", self.project_id.as_str()) - .send() - .await - .unwrap(); - match response.status() { - reqwest::StatusCode::OK => { - let TxByHash { cbor } = response.json::().await.unwrap(); - let tx = cbor::decode(&hex::decode(cbor).unwrap()).unwrap(); - txs.push(tx); - } - status => { - panic!("unexpected response status from Blockfrost: {}", status); - } - }; + if let Some(tx) = self.transaction_by_hash(&tx_hash).await { + txs.push(tx) + } } - txs } + pub async fn transaction_by_hash(&self, tx_hash: &str) -> Option { + // NOTE: Not part of the Rust SDK somehow... + let response = self + .client + .get(&format!( + "https://cardano-{}.blockfrost.io/api/v0/txs/{}/cbor", + self.network_prefix, tx_hash + )) + .header("Accept", "application/json") + .header("project_id", self.project_id.as_str()) + .send() + .await + .unwrap(); + + match response.status() { + reqwest::StatusCode::OK => { + let TxByHash { cbor } = response.json::().await.unwrap(); + let tx = cbor::decode(&hex::decode(cbor).unwrap()).unwrap(); + Some(tx) + } + _ => None, + } + } + pub async fn resolve(&self, input: &TransactionInput) -> Option { let utxo = self .api diff --git a/cli/src/cmd/delegate.rs b/cli/src/cmd/delegate.rs new file mode 100644 index 0000000..bcacb88 --- /dev/null +++ b/cli/src/cmd/delegate.rs @@ -0,0 +1,343 @@ +use crate::{cardano::Cardano, contract::*, pallas_extra::*}; + +use pallas_codec::utils::{Bytes, NonZeroInt, Nullable, PositiveCoin, Set}; +use pallas_crypto::hash::Hash; +use pallas_primitives::conway::{ + Certificate, Language, PlutusV3Script, PostAlonzoTransactionOutput, PseudoTransactionOutput, + RedeemerTag, RedeemersKey, RedeemersValue, StakeCredential, TransactionBody, Tx, Value, + WitnessSet, +}; +use uplc::tx::ResolvedInput; + +pub(crate) async fn delegate( + network: Cardano, + validator: Bytes, + administrators: Vec>, + delegates: Vec>, + quorum: usize, + OutputReference(fuel): OutputReference, +) -> Tx { + let (validator_hash, validator_address) = + from_validator(validator.as_ref(), network.network_id()); + + let params = network.protocol_parameters().await; + + let fuel_output = network + .resolve(&fuel) + .await + .expect("failed to resolve fuel UTxO"); + + let resolved_inputs = &[ResolvedInput { + input: fuel.clone(), + output: PseudoTransactionOutput::PostAlonzo(fuel_output.clone()), + }]; + + let build_params = ( + params.fee_coefficient, + params.fee_constant, + (params.price_mem, params.price_steps), + ); + + build_transaction(build_params, resolved_inputs, |fee, ex_units| { + let (rules, asset_name) = build_rules(&delegates[..], quorum); + + let contract_output = + new_min_value_output(params.min_utxo_deposit_coefficient, |lovelace| { + PostAlonzoTransactionOutput { + address: validator_address.to_vec().into(), + value: Value::Multiasset( + lovelace, + singleton_assets( + validator_hash, + &[(asset_name.clone(), PositiveCoin::try_from(1).unwrap())], + ), + ), + datum_option: None, + script_ref: None, + } + }); + + let total_collateral = (fee as f64 * params.collateral_percent).ceil() as u64; + + let mut redeemers = vec![]; + + let inputs = vec![fuel.clone()]; + + let total_cost = params.drep_deposit + lovelace_of(&contract_output.value) + fee; + + let outputs = vec![ + // Contract + contract_output, + // Change + PostAlonzoTransactionOutput { + address: fuel_output.address.clone(), + value: subtract(fuel_output.value.clone(), total_cost).expect("not enough fuel"), + datum_option: None, + script_ref: None, + }, + ]; + + let collateral_return = PostAlonzoTransactionOutput { + address: fuel_output.address.clone(), + value: subtract(fuel_output.value.clone(), total_collateral).expect("not enough fuel"), + datum_option: None, + script_ref: None, + }; + + let mint = singleton_assets( + validator_hash, + &[(asset_name, NonZeroInt::try_from(1).unwrap())], + ); + redeemers.push(( + RedeemersKey { + tag: RedeemerTag::Mint, + index: 0, + }, + RedeemersValue { + data: void(), + ex_units: ex_units[0], + }, + )); + + let certificates = vec![Certificate::RegDRepCert( + StakeCredential::Scripthash(validator_hash), + params.drep_deposit, + Nullable::Null, + )]; + redeemers.push(( + RedeemersKey { + tag: RedeemerTag::Cert, + index: 0, + }, + RedeemersValue { + data: rules, + ex_units: ex_units[1], + }, + )); + + // ----- Put it all together + let redeemers = non_empty_pairs(redeemers).unwrap(); + Tx { + transaction_body: TransactionBody { + inputs: Set::from(inputs), + network_id: Some(from_network(network.network_id())), + outputs: into_outputs(outputs), + mint: Some(mint), + certificates: non_empty_set(certificates), + fee, + collateral: non_empty_set(vec![fuel.clone()]), + collateral_return: Some(PseudoTransactionOutput::PostAlonzo(collateral_return)), + total_collateral: Some(total_collateral), + required_signers: non_empty_set(administrators.clone()), + script_data_hash: Some( + script_integrity_hash( + Some(&redeemers), + None, + &[(Language::PlutusV3, ¶ms.cost_model_v3[..])], + ) + .unwrap(), + ), + ..default_transaction_body() + }, + transaction_witness_set: WitnessSet { + redeemer: Some(redeemers.into()), + plutus_v3_script: non_empty_set(vec![PlutusV3Script(validator.clone())]), + ..default_witness_set() + }, + success: true, + auxiliary_data: Nullable::Null, + } + }) +} + +pub(crate) async fn redelegate( + network: Cardano, + administrators: Vec>, + delegates: Vec>, + quorum: usize, + OutputReference(contract): OutputReference, + OutputReference(fuel): OutputReference, +) -> Tx { + let (validator, validator_hash, validator_address) = + recover_validator(&network, &contract.transaction_id).await; + + let params = network.protocol_parameters().await; + + let contract_old_output = network + .resolve(&contract) + .await + .expect("failed to resolve contract UTxO"); + + let fuel_output = network + .resolve(&fuel) + .await + .expect("failed to resolve fuel UTxO"); + + let resolved_inputs = &[ + ResolvedInput { + input: contract.clone(), + output: PseudoTransactionOutput::PostAlonzo(contract_old_output.clone()), + }, + ResolvedInput { + input: fuel.clone(), + output: PseudoTransactionOutput::PostAlonzo(fuel_output.clone()), + }, + ]; + + let build_params = ( + params.fee_coefficient, + params.fee_constant, + (params.price_mem, params.price_steps), + ); + + build_transaction(build_params, resolved_inputs, |fee, ex_units| { + let (rules, new_asset_name) = build_rules(&delegates[..], quorum); + + let old_asset_name = find_contract_token(&contract_old_output.value) + .expect("no state token in contract utxo?"); + + let contract_new_output = + new_min_value_output(params.min_utxo_deposit_coefficient, |lovelace| { + PostAlonzoTransactionOutput { + address: validator_address.to_vec().into(), + value: Value::Multiasset( + lovelace, + singleton_assets( + validator_hash, + &[(new_asset_name.clone(), PositiveCoin::try_from(1).unwrap())], + ), + ), + datum_option: None, + script_ref: None, + } + }); + + let total_collateral = (fee as f64 * params.collateral_percent).ceil() as u64; + + let mut redeemers = vec![]; + + let mut inputs = vec![contract.clone(), fuel.clone()]; + inputs.sort(); + + let total_cost = + lovelace_of(&contract_new_output.value) + fee - lovelace_of(&contract_old_output.value); + + let mint = singleton_assets( + validator_hash, + &[ + (new_asset_name, NonZeroInt::try_from(1).unwrap()), + (old_asset_name, NonZeroInt::try_from(-1).unwrap()), + ], + ); + redeemers.push(( + RedeemersKey { + tag: RedeemerTag::Mint, + index: 0, + }, + RedeemersValue { + data: void(), + ex_units: ex_units[0], + }, + )); + + let outputs = vec![ + // Contract + contract_new_output, + // Change + PostAlonzoTransactionOutput { + address: fuel_output.address.clone(), + value: subtract(fuel_output.value.clone(), total_cost).expect("not enough fuel"), + datum_option: None, + script_ref: None, + }, + ]; + + let collateral_return = PostAlonzoTransactionOutput { + address: fuel_output.address.clone(), + value: subtract(fuel_output.value.clone(), total_collateral).expect("not enough fuel"), + datum_option: None, + script_ref: None, + }; + + redeemers.push(( + RedeemersKey { + tag: RedeemerTag::Spend, + index: inputs + .iter() + .enumerate() + .find(|(_, i)| *i == &contract) + .unwrap() + .0 as u32, + }, + RedeemersValue { + data: void(), + ex_units: ex_units[1], + }, + )); + + let certificates = vec![ + Certificate::UnRegDRepCert( + StakeCredential::Scripthash(validator_hash), + params.drep_deposit, + ), + Certificate::RegDRepCert( + StakeCredential::Scripthash(validator_hash), + params.drep_deposit, + Nullable::Null, + ), + ]; + redeemers.push(( + RedeemersKey { + tag: RedeemerTag::Cert, + index: 0, + }, + RedeemersValue { + data: void(), + ex_units: ex_units[2], + }, + )); + redeemers.push(( + RedeemersKey { + tag: RedeemerTag::Cert, + index: 1, + }, + RedeemersValue { + data: rules, + ex_units: ex_units[3], + }, + )); + + // ----- Put it all together + let redeemers = non_empty_pairs(redeemers).unwrap(); + Tx { + transaction_body: TransactionBody { + inputs: Set::from(inputs), + network_id: Some(from_network(network.network_id())), + outputs: into_outputs(outputs), + mint: Some(mint), + certificates: non_empty_set(certificates), + fee, + collateral: non_empty_set(vec![fuel.clone()]), + collateral_return: Some(PseudoTransactionOutput::PostAlonzo(collateral_return)), + total_collateral: Some(total_collateral), + required_signers: non_empty_set(administrators.clone()), + script_data_hash: Some( + script_integrity_hash( + Some(&redeemers), + None, + &[(Language::PlutusV3, ¶ms.cost_model_v3[..])], + ) + .unwrap(), + ), + ..default_transaction_body() + }, + transaction_witness_set: WitnessSet { + redeemer: Some(redeemers.into()), + plutus_v3_script: non_empty_set(vec![PlutusV3Script(validator.clone())]), + ..default_witness_set() + }, + success: true, + auxiliary_data: Nullable::Null, + } + }) +} diff --git a/cli/src/cmd/mod.rs b/cli/src/cmd/mod.rs new file mode 100644 index 0000000..0e70bd0 --- /dev/null +++ b/cli/src/cmd/mod.rs @@ -0,0 +1,317 @@ +use crate::pallas_extra::OutputReference; +use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command}; +use indoc::indoc; +use pallas_codec::utils::Bytes; +use pallas_crypto::hash::{Hash, Hasher}; +use pallas_primitives::conway::{Anchor, GovActionId, Vote}; + +mod delegate; +pub(crate) use delegate::{delegate, redelegate}; + +mod revoke; +pub(crate) use revoke::revoke; + +mod vote; +pub(crate) use vote::vote; + +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub(crate) enum ParseFailure { + OutputReference(&'static str, String), + HexString(&'static str, hex::FromHexError), + Int(&'static str, std::num::ParseIntError), +} + +pub(crate) fn cli() -> Command { + Command::new("Hot/Cold DRep Management") + .version("1.0.0") + .about("A toolkit providing hot/cold account management for delegate representatives on Cardano. +This command-line serves as a transaction builder various steps of the contract.") + .subcommand( + Command::new("vote") + .about("Vote on a governance action.") + .after_help(color_print::cstr!( + r#"Notes: + 1. The specified --delegate must reflect the signatories for the transaction, but not necessarily ALL delegates. + Only those authorizing the transaction must be present. And, there must be enough signatories for a quorum. + +Example: + vote \ + --yes \ + --proposal "2ad082a4f85d4a66e8bb240ecd147a8351228ebd0995bef90c4d14f61d4b19cc#0" \ + --anchor "https://metadata.cardanoapi.io/data/climate \ + --delegate 000000000000000000000000000000000000000000000000000a11ce \ + --contract "8d5726c0e7cb207a3f5881d29a7ceba71f578c2165a2261340c242bdba6875dd#0" \ + --fuel "ab5334d2db6f7909b511ee9c0f7181c7f4da515ba15f186d95caef0d91ac4a11#0" +"# )) + .arg(arg_proposal()) + .arg(arg_anchor()) + .arg(flag_yes()) + .arg(flag_no()) + .arg(flag_abstain()) + .arg(arg_delegate()) + .arg(arg_contract(true)) + .arg(arg_fuel()) + .group(ArgGroup::new("vote") + .args(["yes", "no", "abstain"]) + .multiple(false) + .required(true) + ) + + ) + .subcommand( + Command::new("delegate") + .about(indoc! { + r#"Hand-over voting rights to a group of delegates (hot credentials)."# + }) + .after_help(color_print::cstr!( + r#"Notes: + 1. The --contract option is only mandatory for re-delegation (as it typically doesn't exist otherwise). + 2. The specified --administrator must reflect the signatories for the transaction, but not necessarily ALL administrators. + Only those authorizing the transaction must be present. And, there must be enough signatories for a quorum. + +Examples: +1. No previous contract instance, defining a 1-of-2 hot delegate: + delegate \ + --quorum 1 \ + --delegate 000000000000000000000000000000000000000000000000000a11ce \ + --delegate 00000000000000000000000000000000000000000000000000000b0b \ + --validator $(jq -r ".validators[0].compiledCode" plutus.json) \ + --administrator 0000000000000000000000000000000000000000000000000000090d \ + --fuel "ab5334d2db6f7909b511ee9c0f7181c7f4da515ba15f186d95caef0d91ac4a11#0" + +2. Re-delegation, defining now a 2-of-3 hot delegate: + delegate \ + --quorum 2 \ + --delegate 000000000000000000000000000000000000000000000000000a11ce \ + --delegate 00000000000000000000000000000000000000000000000000000b0b \ + --delegate 000000000000000000000000000000000000000000000000000ca201 \ + --contract "8d5726c0e7cb207a3f5881d29a7ceba71f578c2165a2261340c242bdba6875dd#0" \ + --administrator 0000000000000000000000000000000000000000000000000000090d \ + --fuel "ab5334d2db6f7909b511ee9c0f7181c7f4da515ba15f186d95caef0d91ac4a11#0" +"# )) + .arg(arg_delegate()) + .arg(arg_quorum()) + .arg(arg_validator()) + .arg(arg_contract(false)) + .arg(arg_administrator()) + .arg(arg_fuel()) + .group(ArgGroup::new("source") + .args(["contract", "validator"]) + .multiple(false) + .required(true) + ) + ) + .subcommand( + Command::new("revoke") + .about("Revoke delegation, without defining a new delegate.") + ) +} + +// --------------------------------------------------------- options & flags ---- + +// ----------------------------------------------------------- administrator ---- + +const ARG_ADMINISTRATOR: &str = "administrator"; + +fn arg_administrator() -> Arg { + Arg::new(ARG_ADMINISTRATOR) + .long(ARG_ADMINISTRATOR) + .short('a') + .value_name("HEX_STRING") + .help("Verification key hash digest (blake2b-228) of an admin signatory. Use multiple times for multiple admins.") + .action(ArgAction::Append) +} + +pub(crate) fn get_arg_administrators(args: &ArgMatches) -> Result>, ParseFailure> { + args.get_many::(ARG_ADMINISTRATOR) + .unwrap_or_default() + .map(|admin| admin.parse()) + .collect::>() + .map_err(|e| ParseFailure::HexString(ARG_ADMINISTRATOR, e)) +} + +// ----------------------------------------------------------------- anchor ---- + +fn arg_anchor() -> Arg { + Arg::new("anchor") + .long("anchor") + .short('a') + .value_name("URL") + .help("An (optional) URL to an anchor file containing rationale for the vote.") + .action(ArgAction::Set) +} + +pub(crate) async fn get_arg_anchor(args: &ArgMatches) -> Option { + if let Some(url) = args.get_one::("anchor") { + let response = reqwest::get(url) + .await + .expect("failed to fetch anchor at URL: {url}"); + match response.status() { + status if status.is_success() => { + let content_hash = Hasher::<256>::hash(response.bytes().await.unwrap().as_ref()); + Some(Anchor { + url: url.to_string(), + content_hash, + }) + } + status => panic!("failed to fetch anchor content, server said: {status:?}"), + } + } else { + None + } +} + +// --------------------------------------------------------------- contract ---- + +fn arg_contract(required: bool) -> Arg { + Arg::new("contract") + .long("contract") + .short('c') + .value_name("TX_ID#IX") + .help("The UTxO holding the contract's state.") + .required(required) + .action(ArgAction::Set) +} + +pub(crate) fn get_arg_contract(args: &ArgMatches) -> Result, ParseFailure> { + args.get_one::("contract") + .map(|s| s.parse()) + .transpose() + .map_err(|e| ParseFailure::OutputReference("contract", e)) +} + +// --------------------------------------------------------------- delegate ---- + +fn arg_delegate() -> Arg { + Arg::new("delegate") + .long("delegate") + .short('d') + .value_name("HEX_STRING") + .help("Verification key hash digest (blake2b-228) of a delegate signatory. Use multiple times for multiple delegates.") + .action(ArgAction::Append) +} + +pub(crate) fn get_arg_delegates(args: &ArgMatches) -> Result>, ParseFailure> { + args.get_many::("delegate") + .unwrap_or_default() + .map(|delegate| delegate.parse()) + .collect::>() + .map_err(|e| ParseFailure::HexString("delegate", e)) +} + +// ------------------------------------------------------------------- fuel ---- + +fn arg_fuel() -> Arg { + Arg::new("fuel") + .long("fuel") + .short('f') + .required(true) + .value_name("TX_ID#IX") + .help("A UTxO to use as fuel for the transaction. Must be suitable for collateral use.") + .action(ArgAction::Set) +} + +pub(crate) fn get_arg_fuel(args: &ArgMatches) -> Result { + args.get_one::("fuel") + .unwrap() + .parse() + .map_err(|e| ParseFailure::OutputReference("fuel", e)) +} + +// --------------------------------------------------------------- proposal ---- + +fn arg_proposal() -> Arg { + Arg::new("proposal") + .long("proposal") + .short('p') + .required(true) + .value_name("TX_ID#IX") + .help("The proposal procedure identifier that's being voted on.") + .action(ArgAction::Set) +} + +pub(crate) fn get_arg_proposal(args: &ArgMatches) -> Result { + let OutputReference(utxo_like) = args + .get_one::("proposal") + .unwrap() + .parse() + .map_err(|e| ParseFailure::OutputReference("proposal", e))?; + + Ok(GovActionId { + transaction_id: utxo_like.transaction_id, + action_index: utxo_like.index as u32, + }) +} + +// ----------------------------------------------------------------- quorum ---- + +fn arg_quorum() -> Arg { + Arg::new("quorum") + .long("quorum") + .short('q') + .value_name("UINT") + .help("Minimum number of delegates to authorize votes. Default to the total number of delegates (plenum).") + .action(ArgAction::Set) +} + +pub(crate) fn get_arg_quorum(args: &ArgMatches) -> Result, ParseFailure> { + args.get_one::("quorum") + .map(|s| s.parse().map_err(|e| ParseFailure::Int("quorum", e))) + .transpose() +} + +// -------------------------------------------------------------- validator ---- + +fn arg_validator() -> Arg { + Arg::new("validator") + .long("validator") + .short('v') + .value_name("HEX_STRING") + .help("The compiled validator code, hex-encoded. (e.g jq -r '.validators[0].compiledCode' plutus.json)") + .action(ArgAction::Set) +} + +pub(crate) fn get_arg_validator(args: &ArgMatches) -> Result, ParseFailure> { + args.get_one::("validator") + .map(|s| { + hex::decode(s) + .map(Bytes::from) + .map_err(|e| ParseFailure::HexString("validator", e)) + }) + .transpose() +} + +// ------------------------------------------------------------------- vote ---- + +pub(crate) fn get_arg_vote(args: &ArgMatches) -> Vote { + match args.get_one::("vote").unwrap().as_str() { + "yes" => Vote::Yes, + "no" => Vote::No, + "abstain" => Vote::Abstain, + _ => unreachable!(), + } +} + +fn flag_yes() -> Arg { + Arg::new("yes") + .short('y') + .long("yes") + .help("Approve the governance proposal") + .action(ArgAction::SetTrue) +} + +fn flag_no() -> Arg { + Arg::new("no") + .short('n') + .long("no") + .help("Reject the governance proposal") + .action(ArgAction::SetTrue) +} + +fn flag_abstain() -> Arg { + Arg::new("abstain") + .long("abstain") + .help("Abstain from the governance proposal voting") + .action(ArgAction::SetTrue) +} diff --git a/cli/src/cmd/revoke.rs b/cli/src/cmd/revoke.rs new file mode 100644 index 0000000..26f85c5 --- /dev/null +++ b/cli/src/cmd/revoke.rs @@ -0,0 +1,7 @@ +use crate::cardano::Cardano; + +use pallas_primitives::conway::Tx; + +pub(crate) async fn revoke(_network: Cardano) -> Tx { + todo!() +} diff --git a/cli/src/cmd/vote.rs b/cli/src/cmd/vote.rs new file mode 100644 index 0000000..f905c5f --- /dev/null +++ b/cli/src/cmd/vote.rs @@ -0,0 +1,135 @@ +use crate::{cardano::Cardano, contract::*, pallas_extra::*}; +use pallas_codec::utils::{NonEmptyKeyValuePairs, Nullable, Set}; +use pallas_crypto::hash::Hash; +use pallas_primitives::conway::{ + Anchor, GovActionId, Language, PlutusV3Script, PostAlonzoTransactionOutput, + PseudoTransactionOutput, RedeemerTag, RedeemersKey, RedeemersValue, TransactionBody, Tx, Vote, + Voter, VotingProcedure, WitnessSet, +}; +use uplc::tx::ResolvedInput; + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn vote( + network: Cardano, + delegates: Vec>, + choice: Vote, + anchor: Option, + proposal_id: GovActionId, + OutputReference(contract): OutputReference, + OutputReference(fuel): OutputReference, +) -> Tx { + let (validator, validator_hash, _) = + recover_validator(&network, &contract.transaction_id).await; + + let params = network.protocol_parameters().await; + + let contract_output = network + .resolve(&contract) + .await + .expect("failed to resolve contract UTxO"); + + let fuel_output = network + .resolve(&fuel) + .await + .expect("failed to resolve fuel UTxO"); + + let resolved_inputs = &[ + ResolvedInput { + input: contract.clone(), + output: PseudoTransactionOutput::PostAlonzo(contract_output.clone()), + }, + ResolvedInput { + input: fuel.clone(), + output: PseudoTransactionOutput::PostAlonzo(fuel_output.clone()), + }, + ]; + + let (rules, _) = recover_rules(&network, &validator_hash, &contract_output.value).await; + + let build_params = ( + params.fee_coefficient, + params.fee_constant, + (params.price_mem, params.price_steps), + ); + + build_transaction(build_params, resolved_inputs, |fee, ex_units| { + let mut redeemers = vec![]; + + let inputs = vec![fuel.clone()]; + + let reference_inputs = vec![contract.clone()]; + + let outputs = vec![ + // Change + PostAlonzoTransactionOutput { + address: fuel_output.address.clone(), + value: subtract(fuel_output.value.clone(), fee).expect("not enough fuel"), + datum_option: None, + script_ref: None, + }, + ]; + + let total_collateral = (fee as f64 * params.collateral_percent).ceil() as u64; + + let collateral_return = PostAlonzoTransactionOutput { + address: fuel_output.address.clone(), + value: subtract(fuel_output.value.clone(), total_collateral).expect("not enough fuel"), + datum_option: None, + script_ref: None, + }; + + let votes = vec![( + Voter::DRepScript(validator_hash), + NonEmptyKeyValuePairs::Def(vec![( + proposal_id.clone(), + VotingProcedure { + vote: choice.clone(), + anchor: anchor.clone().map(Nullable::Some).unwrap_or(Nullable::Null), + }, + )]), + )]; + redeemers.push(( + RedeemersKey { + tag: RedeemerTag::Vote, + index: 0, + }, + RedeemersValue { + data: rules.clone(), + ex_units: ex_units[0], + }, + )); + + // ----- Put it all together + let redeemers = non_empty_pairs(redeemers).unwrap(); + Tx { + transaction_body: TransactionBody { + inputs: Set::from(inputs), + reference_inputs: non_empty_set(reference_inputs), + network_id: Some(from_network(network.network_id())), + outputs: into_outputs(outputs), + voting_procedures: non_empty_pairs(votes), + fee, + collateral: non_empty_set(vec![fuel.clone()]), + collateral_return: Some(PseudoTransactionOutput::PostAlonzo(collateral_return)), + total_collateral: Some(total_collateral), + required_signers: non_empty_set(delegates.clone()), + script_data_hash: Some( + script_integrity_hash( + Some(&redeemers), + None, + &[(Language::PlutusV3, ¶ms.cost_model_v3[..])], + ) + .unwrap(), + ), + ..default_transaction_body() + }, + transaction_witness_set: WitnessSet { + redeemer: Some(redeemers.into()), + plutus_v3_script: non_empty_set(vec![PlutusV3Script(validator.clone())]), + ..default_witness_set() + }, + success: true, + auxiliary_data: Nullable::Null, + } + }) +} diff --git a/cli/src/contract.rs b/cli/src/contract.rs new file mode 100644 index 0000000..ae8af69 --- /dev/null +++ b/cli/src/contract.rs @@ -0,0 +1,108 @@ +use crate::{cardano::Cardano, pallas_extra::*}; +use pallas_addresses::ShelleyAddress; +use pallas_codec::utils::Bytes; +use pallas_crypto::hash::{Hash, Hasher}; +use pallas_primitives::conway::{AssetName, Constr, PlutusData, RedeemerTag, Value}; + +pub(crate) fn build_rules(delegates: &[Hash<28>], quorum: usize) -> (PlutusData, AssetName) { + assert!( + quorum <= delegates.len(), + "quorum cannot be larger than number of delegates" + ); + + assert!(!delegates.is_empty(), "there must be at least one delegate"); + + let rules = PlutusData::Constr(Constr { + tag: 123, + any_constructor: None, + fields: vec![PlutusData::Array( + delegates + .iter() + .map(|delegate| { + PlutusData::Constr(Constr { + tag: 121, + any_constructor: None, + fields: vec![PlutusData::BoundedBytes( + delegate.as_slice().to_vec().into(), + )], + }) + }) + .collect::>(), + )], + }); + + let mut asset_name = "gov_".as_bytes().to_vec(); + asset_name.extend(Hasher::<224>::hash_cbor(&rules).as_slice()); + + (rules, asset_name.into()) +} + +// To avoid re-asking users for the delegates and quorum during vote (which is (1) inconvenient, +// and (2), utterly confusing with the existing delegates signatories...), we pull the rules from +// the minting transaction corresponding to the current state token. The token is always minted +// alongside a DRep registration certificate which defines the new rules as redeemer. +pub(crate) async fn recover_rules( + network: &Cardano, + validator_hash: &Hash<28>, + contract_value: &Value, +) -> (PlutusData, AssetName) { + let asset_name = find_contract_token(contract_value).expect("no state token in contract utxo?"); + + let minting_txs = network.minting(validator_hash, &asset_name).await; + + let minting_tx = minting_txs.first().unwrap_or_else(|| { + panic!( + "no minting transaction found for {}", + hex::encode(&asset_name[..]), + ) + }); + + let rules = if let Some(ref redeemers) = minting_tx.transaction_witness_set.redeemer { + redeemers + .iter() + .find_map(|(key, value)| { + if key.tag == RedeemerTag::Cert && value.data != void() { + Some(value.data.clone()) + } else { + None + } + }) + .expect("could not find registration certificate alongside minting transaction?!") + } else { + unreachable!() + }; + + (rules, asset_name) +} + +pub(crate) async fn recover_validator( + network: &Cardano, + transaction_id: &Hash<32>, +) -> (Bytes, Hash<28>, ShelleyAddress) { + let validator = network + .transaction_by_hash(&hex::encode(transaction_id)) + .await + .expect("Could not resolve contract UTxO?") + .transaction_witness_set + .plutus_v3_script + .expect("No Plutus script found in the provided contract UTxO?") + .first() + .unwrap() + .to_owned() + .0; + + let (validator_hash, validator_address) = + from_validator(validator.as_ref(), network.network_id()); + + (validator, validator_hash, validator_address) +} + +pub(crate) fn find_contract_token(value: &Value) -> Option { + match value { + Value::Multiasset(_, ref assets) => assets + .first() + .and_then(|(_, assets)| assets.first().cloned()), + _ => None, + } + .map(|pair| pair.0) +} diff --git a/cli/src/main.rs b/cli/src/main.rs index ddcb3c4..2e89a08 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,333 +1,50 @@ -use crate::cardano::ProtocolParameters; use cardano::Cardano; -use clap::{Arg, ArgAction, ArgGroup, Command}; -use indoc::{indoc, printdoc}; -use pallas_addresses::{ - Address, Network, ShelleyAddress, ShelleyDelegationPart, ShelleyPaymentPart, -}; -use pallas_codec::{ - minicbor as cbor, - utils::{Bytes, NonEmptyKeyValuePairs, NonEmptySet, NonZeroInt, Nullable, PositiveCoin, Set}, -}; -use pallas_crypto::hash::{Hash, Hasher}; -use pallas_primitives::conway::{ - Anchor, AssetName, Certificate, Constr, DRep, ExUnits, GovActionId, Language, Multiasset, - NetworkId, PlutusData, PlutusV3Script, PostAlonzoTransactionOutput, PseudoTransactionOutput, - RedeemerTag, RedeemersKey, RedeemersValue, StakeCredential, TransactionBody, TransactionInput, - Tx, Value, Vote, Voter, VotingProcedure, WitnessSet, -}; -use std::{cmp::Ordering, num, str::FromStr}; -use uplc::tx::{eval_phase_two, ResolvedInput, SlotConfig}; +use indoc::printdoc; +use pallas_codec::minicbor as cbor; +use pallas_primitives::conway::Tx; mod cardano; - -// ------------------------------------------------------------------ main ---- +mod cmd; +mod contract; +mod pallas_extra; #[tokio::main] -async fn main() -> Result<(), Error> { +async fn main() -> Result<(), cmd::ParseFailure> { let network = Cardano::new(); - match cli().get_matches().subcommand() { - Some(("assign-stake", args)) => { - let validator = hex::decode(args.get_one::("validator").unwrap()) - .map_err(|e| Error::FailedToDecodeHexString("validator", e))? - .into(); - - let fuel = args.get_one::("fuel").unwrap().parse()?; - - report(assign_stake(network, validator, fuel).await?) - } - + match cmd::cli().get_matches().subcommand() { Some(("delegate", args)) => { - let validator = hex::decode(args.get_one::("validator").unwrap()) - .map_err(|e| Error::FailedToDecodeHexString("validator", e))? - .into(); - - let contract = args - .get_one::("contract") - .map(|s| s.parse()) - .transpose()?; - - let administrators = args - .get_many::("administrator") - .unwrap_or_default() - .map(|admin| admin.parse()) - .collect::>, _>>() - .map_err(|e| Error::FailedToDecodeHexString("administrator", e))?; - - let delegates = args - .get_many::("delegate") - .unwrap_or_default() - .map(|delegate| delegate.parse()) - .collect::>, _>>() - .map_err(|e| Error::FailedToDecodeHexString("delegate", e))?; - - let quorum = args - .get_one::("quorum") - .map(|s| s.parse().map_err(|e| Error::FailedToDecodeInt("quorum", e))) - .transpose()? - .unwrap_or(delegates.len()); - - let fuel = args.get_one::("fuel").unwrap().parse()?; + let contract = cmd::get_arg_contract(args)?; + let administrators = cmd::get_arg_administrators(args)?; + let delegates = cmd::get_arg_delegates(args)?; + let quorum = cmd::get_arg_quorum(args)?.unwrap_or(delegates.len()); + let fuel = cmd::get_arg_fuel(args)?; report(if let Some(contract) = contract { - redelegate( - network, - validator, - administrators, - delegates, - quorum, - contract, - fuel, - ) - .await? + cmd::redelegate(network, administrators, delegates, quorum, contract, fuel).await } else { - delegate(network, validator, administrators, delegates, quorum, fuel).await? + let validator = cmd::get_arg_validator(args)?.unwrap(); + cmd::delegate(network, validator, administrators, delegates, quorum, fuel).await }) } Some(("vote", args)) => { - let validator = hex::decode(args.get_one::("validator").unwrap()) - .map_err(|e| Error::FailedToDecodeHexString("validator", e))? - .into(); - - let delegates = args - .get_many::("delegate") - .unwrap_or_default() - .map(|delegate| delegate.parse()) - .collect::>, _>>() - .map_err(|e| Error::FailedToDecodeHexString("delegate", e))?; - - let choice = match args.get_one::("vote").unwrap().as_str() { - "yes" => Vote::Yes, - "no" => Vote::No, - "abstain" => Vote::Abstain, - _ => unreachable!(), - }; - - let anchor = args.get_one::("anchor").map(|s| s.as_str()); + let delegates = cmd::get_arg_delegates(args)?; + let choice = cmd::get_arg_vote(args); + let anchor = cmd::get_arg_anchor(args).await; + let proposal = cmd::get_arg_proposal(args)?; + let contract = cmd::get_arg_contract(args)?.unwrap(); + let fuel = cmd::get_arg_fuel(args)?; - let OutputReference(utxo_like) = args.get_one::("proposal").unwrap().parse()?; - let proposal_id = GovActionId { - transaction_id: utxo_like.transaction_id, - action_index: utxo_like.index as u32, - }; - - let contract = args.get_one::("contract").unwrap().parse()?; - - let fuel = args.get_one::("fuel").unwrap().parse()?; - - report( - vote( - network, - validator, - delegates, - choice, - anchor, - proposal_id, - contract, - fuel, - ) - .await?, - ) + report(cmd::vote(network, delegates, choice, anchor, proposal, contract, fuel).await) } - Some(("revoke", _)) => Ok(()), + Some(("revoke", _)) => report(cmd::revoke(network).await), _ => unreachable!(), } } -struct OutputReference(TransactionInput); - -impl FromStr for OutputReference { - type Err = Error; - fn from_str(s: &str) -> Result { - match &s.split('#').collect::>()[..] { - [tx_id_str, ix_str] => { - let transaction_id: Hash<32> = tx_id_str - .parse() - .map_err(|e| Error::FailedToDecodeHexString("transaction id", e))?; - let index: u64 = ix_str - .parse() - .map_err(|e| Error::FailedToDecodeInt("output index", e))?; - Ok(OutputReference(TransactionInput { - transaction_id, - index, - })) - } - _ => Err(Error::MalformedOutputReference), - } - } -} - -// ---------------------------------------------------------------- errors ---- - -#[allow(dead_code)] -#[derive(Debug, Clone)] -enum Error { - FailedToDecodeHexString(&'static str, hex::FromHexError), - MalformedOutputReference, - FailedToDecodeInt(&'static str, num::ParseIntError), -} - -// ------------------------------------------------------------------- cli ---- - -fn cli() -> Command { - Command::new("Hot/Cold DRep Management") - .version("1.0.0") - .about("A toolkit providing hot/cold account management for delegate representatives on Cardano. -This command-line serves as a transaction builder various steps of the contract.") - .subcommand( - Command::new("vote") - .about("Vote on a governance action.") - .arg(arg_contract(true)) - .arg(arg_validator()) - .arg(arg_delegate()) - .arg(arg_fuel()) - .arg(arg_proposal()) - .arg(arg_anchor()) - .arg(flag_yes()) - .arg(flag_no()) - .arg(flag_abstain()) - .group(arg_vote()) - ) - .subcommand( - Command::new("delegate") - .about(indoc! { - r#"Hand-over voting rights to a delegate script. The --contract option is only mandatory for re-delegation (as it typically doesn't exist otherwise). - Also, the specified --administrator must reflect the signatories for the transaction, but not necessarily ALL administrators. Only those authorizing - the transaction must be present. And, there must be enough signatories for a quorum."# - }) - .arg(arg_validator()) - .arg(arg_delegate()) - .arg(arg_administrator()) - .arg(arg_quorum()) - .arg(arg_contract(false)) - .arg(arg_fuel()) - ) - .subcommand( - Command::new("assign-stake") - .about(indoc! { - r#"Assign all stake from the fuel input to the DRep contract. This command exists mainly for testing purposes, as an easy way to get some stake into - the DRep."# - }) - .arg(arg_validator()) - .arg(arg_fuel()) - ) - .subcommand( - Command::new("revoke") - .about("Revoke delegation, without defining a new delegate.") - ) -} - -// ------------------------------------------------------------- arguments ---- - -fn arg_validator() -> Arg { - Arg::new("validator") - .long("validator") - .short('v') - .value_name("HEX_STRING") - .help("The compiled validator code, hex-encoded. (e.g jq -r '.validators[0].compiledCode' plutus.json)") - .action(ArgAction::Set) -} - -fn arg_anchor() -> Arg { - Arg::new("anchor") - .long("anchor") - .short('a') - .value_name("URL") - .help("An (optional) URL to an anchor file containing rationale for the vote.") - .action(ArgAction::Set) -} - -fn arg_vote() -> ArgGroup { - ArgGroup::new("vote") - .args(["yes", "no", "abstain"]) - .multiple(true) - .required(true) -} - -fn flag_yes() -> Arg { - Arg::new("yes") - .short('y') - .long("yes") - .help("Approve the governance proposal") - .action(ArgAction::SetTrue) -} - -fn flag_no() -> Arg { - Arg::new("no") - .short('n') - .long("no") - .help("Reject the governance proposal") - .action(ArgAction::SetTrue) -} - -fn flag_abstain() -> Arg { - Arg::new("abstain") - .long("abstain") - .help("Abstain from the governance proposal voting") - .action(ArgAction::SetTrue) -} - -fn arg_contract(required: bool) -> Arg { - Arg::new("contract") - .long("contract") - .short('c') - .value_name("TX_ID#IX") - .help("The UTxO holding the contract's state.") - .required(required) - .action(ArgAction::Set) -} - -fn arg_fuel() -> Arg { - Arg::new("fuel") - .long("fuel") - .short('f') - .required(true) - .value_name("TX_ID#IX") - .help("A UTxO to use as fuel for the transaction. Must be suitable for collateral use.") - .action(ArgAction::Set) -} - -fn arg_proposal() -> Arg { - Arg::new("proposal") - .long("proposal") - .short('p') - .required(true) - .value_name("TX_ID#IX") - .help("The proposal procedure identifier that's being voted on.") - .action(ArgAction::Set) -} - -fn arg_delegate() -> Arg { - Arg::new("delegate") - .long("delegate") - .short('s') - .value_name("HEX_STRING") - .help("Verification key hash digest (blake2b-228) of a delegate signatory. Use multiple times for multiple delegates.") - .action(ArgAction::Append) -} - -fn arg_administrator() -> Arg { - Arg::new("administrator") - .long("administrator") - .short('a') - .value_name("HEX_STRING") - .help("Verification key hash digest (blake2b-228) of an admin signatory. Use multiple times for multiple admins.") - .action(ArgAction::Append) -} - -fn arg_quorum() -> Arg { - Arg::new("quorum") - .long("quorum") - .short('q') - .value_name("UINT") - .help("Minimum number of delegates to authorize votes. Default to the total number of delegates (plenum).") - .action(ArgAction::Set) -} - // -------------------------------------------------------------- commands ---- fn report(tx: Tx) -> Result<(), E> { @@ -343,896 +60,3 @@ fn report(tx: Tx) -> Result<(), E> { }; Ok(()) } - -async fn assign_stake( - network: Cardano, - validator: Bytes, - OutputReference(fuel): OutputReference, -) -> Result { - let (validator_hash, _) = from_validator(validator.as_ref(), network.network_id()); - - let params = network.protocol_parameters().await; - - let fuel_output = network - .resolve(&fuel) - .await - .expect("failed to resolve fuel UTxO"); - - build_transaction(¶ms, &[], |fee, _| { - let inputs = vec![fuel.clone()]; - - let (vkh, address) = - if let Ok(Address::Shelley(src)) = Address::from_bytes(&fuel_output.address) { - let payment_part = src.payment().clone(); - let (vkh, delegation_part) = match payment_part { - ShelleyPaymentPart::Key(vkh) => (vkh, ShelleyDelegationPart::Key(vkh)), - ShelleyPaymentPart::Script(..) => unreachable!(), - }; - ( - vkh, - ShelleyAddress::new(src.network(), payment_part, delegation_part), - ) - } else { - unreachable!(); - }; - - let total_cost = fee + 2_000_000; - - let outputs = vec![PostAlonzoTransactionOutput { - address: address.to_vec().into(), - value: subtract(fuel_output.value.clone(), total_cost).expect("not enough fuel"), - datum_option: None, - script_ref: None, - }]; - - let certificates = vec![Certificate::VoteRegDeleg( - StakeCredential::AddrKeyhash(vkh), - DRep::Script(validator_hash), - 2_000_000, - )]; - - Tx { - transaction_body: TransactionBody { - inputs: Set::from(inputs), - outputs: outputs - .into_iter() - .map(PseudoTransactionOutput::PostAlonzo) - .collect(), - fee, - certificates: Some(NonEmptySet::try_from(certificates).unwrap()), - ..default_transaction_body() - }, - transaction_witness_set: default_witness_set(), - success: true, - auxiliary_data: Nullable::Null, - } - }) -} - -async fn delegate( - network: Cardano, - validator: Bytes, - administrators: Vec>, - delegates: Vec>, - quorum: usize, - OutputReference(fuel): OutputReference, -) -> Result { - let (validator_hash, validator_address) = - from_validator(validator.as_ref(), network.network_id()); - - let params = network.protocol_parameters().await; - - let fuel_output = network - .resolve(&fuel) - .await - .expect("failed to resolve fuel UTxO"); - - let resolved_inputs = &[ResolvedInput { - input: fuel.clone(), - output: PseudoTransactionOutput::PostAlonzo(fuel_output.clone()), - }]; - - build_transaction(¶ms, resolved_inputs, |fee, ex_units| { - let (rules, asset_name) = build_rules(&delegates[..], quorum); - - let contract_output = - new_min_value_output(params.min_utxo_deposit_coefficient, |lovelace| { - PostAlonzoTransactionOutput { - address: validator_address.to_vec().into(), - value: Value::Multiasset( - lovelace, - singleton_assets( - validator_hash, - &[(asset_name.clone(), PositiveCoin::try_from(1).unwrap())], - ), - ), - datum_option: None, - script_ref: None, - } - }); - - let total_collateral = (fee as f64 * params.collateral_percent).ceil() as u64; - - let mut redeemers = vec![]; - - let inputs = vec![fuel.clone()]; - - let total_cost = params.drep_deposit + lovelace_of(&contract_output.value) + fee; - - let outputs = vec![ - // Contract - contract_output, - // Change - PostAlonzoTransactionOutput { - address: fuel_output.address.clone(), - value: subtract(fuel_output.value.clone(), total_cost).expect("not enough fuel"), - datum_option: None, - script_ref: None, - }, - ]; - - let collateral_return = PostAlonzoTransactionOutput { - address: fuel_output.address.clone(), - value: subtract(fuel_output.value.clone(), total_collateral).expect("not enough fuel"), - datum_option: None, - script_ref: None, - }; - - let mint = singleton_assets( - validator_hash, - &[(asset_name, NonZeroInt::try_from(1).unwrap())], - ); - redeemers.push(( - RedeemersKey { - tag: RedeemerTag::Mint, - index: 0, - }, - RedeemersValue { - data: void(), - ex_units: ex_units[0], - }, - )); - - let certificates = vec![Certificate::RegDRepCert( - StakeCredential::Scripthash(validator_hash), - params.drep_deposit, - Nullable::Null, - )]; - redeemers.push(( - RedeemersKey { - tag: RedeemerTag::Cert, - index: 0, - }, - RedeemersValue { - data: rules, - ex_units: ex_units[1], - }, - )); - - // ----- Put it all together - let redeemers = NonEmptyKeyValuePairs::Def(redeemers); - Tx { - transaction_body: new_transaction_body( - network.network_id(), - inputs, - vec![], - outputs, - Some(mint), - certificates, - vec![], - (vec![fuel.clone()], collateral_return, total_collateral), - fee, - administrators.clone(), - script_integrity_hash( - Some(&redeemers), - None, - &[(Language::PlutusV3, ¶ms.cost_model_v3[..])], - ) - .unwrap(), - ), - transaction_witness_set: new_witness_set(redeemers, validator.clone()), - success: true, - auxiliary_data: Nullable::Null, - } - }) -} - -async fn redelegate( - network: Cardano, - validator: Bytes, - administrators: Vec>, - delegates: Vec>, - quorum: usize, - OutputReference(contract): OutputReference, - OutputReference(fuel): OutputReference, -) -> Result { - let (validator_hash, validator_address) = - from_validator(validator.as_ref(), network.network_id()); - - let params = network.protocol_parameters().await; - - let contract_old_output = network - .resolve(&contract) - .await - .expect("failed to resolve contract UTxO"); - - let fuel_output = network - .resolve(&fuel) - .await - .expect("failed to resolve fuel UTxO"); - - let resolved_inputs = &[ - ResolvedInput { - input: contract.clone(), - output: PseudoTransactionOutput::PostAlonzo(contract_old_output.clone()), - }, - ResolvedInput { - input: fuel.clone(), - output: PseudoTransactionOutput::PostAlonzo(fuel_output.clone()), - }, - ]; - - build_transaction(¶ms, resolved_inputs, |fee, ex_units| { - let (rules, new_asset_name) = build_rules(&delegates[..], quorum); - - let old_asset_name = find_contract_token(&contract_old_output.value) - .expect("no state token in contract utxo?"); - - let contract_new_output = - new_min_value_output(params.min_utxo_deposit_coefficient, |lovelace| { - PostAlonzoTransactionOutput { - address: validator_address.to_vec().into(), - value: Value::Multiasset( - lovelace, - singleton_assets( - validator_hash, - &[(new_asset_name.clone(), PositiveCoin::try_from(1).unwrap())], - ), - ), - datum_option: None, - script_ref: None, - } - }); - - let total_collateral = (fee as f64 * params.collateral_percent).ceil() as u64; - - let mut redeemers = vec![]; - - let mut inputs = vec![contract.clone(), fuel.clone()]; - inputs.sort(); - - let total_cost = - lovelace_of(&contract_new_output.value) + fee - lovelace_of(&contract_old_output.value); - - let mint = singleton_assets( - validator_hash, - &[ - (new_asset_name, NonZeroInt::try_from(1).unwrap()), - (old_asset_name, NonZeroInt::try_from(-1).unwrap()), - ], - ); - redeemers.push(( - RedeemersKey { - tag: RedeemerTag::Mint, - index: 0, - }, - RedeemersValue { - data: void(), - ex_units: ex_units[0], - }, - )); - - let outputs = vec![ - // Contract - contract_new_output, - // Change - PostAlonzoTransactionOutput { - address: fuel_output.address.clone(), - value: subtract(fuel_output.value.clone(), total_cost).expect("not enough fuel"), - datum_option: None, - script_ref: None, - }, - ]; - - let collateral_return = PostAlonzoTransactionOutput { - address: fuel_output.address.clone(), - value: subtract(fuel_output.value.clone(), total_collateral).expect("not enough fuel"), - datum_option: None, - script_ref: None, - }; - - redeemers.push(( - RedeemersKey { - tag: RedeemerTag::Spend, - index: inputs - .iter() - .enumerate() - .find(|(_, i)| *i == &contract) - .unwrap() - .0 as u32, - }, - RedeemersValue { - data: void(), - ex_units: ex_units[1], - }, - )); - - let certificates = vec![ - Certificate::UnRegDRepCert( - StakeCredential::Scripthash(validator_hash), - params.drep_deposit, - ), - Certificate::RegDRepCert( - StakeCredential::Scripthash(validator_hash), - params.drep_deposit, - Nullable::Null, - ), - ]; - redeemers.push(( - RedeemersKey { - tag: RedeemerTag::Cert, - index: 0, - }, - RedeemersValue { - data: void(), - ex_units: ex_units[2], - }, - )); - redeemers.push(( - RedeemersKey { - tag: RedeemerTag::Cert, - index: 1, - }, - RedeemersValue { - data: rules, - ex_units: ex_units[3], - }, - )); - - // ----- Put it all together - let redeemers = NonEmptyKeyValuePairs::Def(redeemers); - Tx { - transaction_body: new_transaction_body( - network.network_id(), - inputs, - vec![], - outputs, - Some(mint), - certificates, - vec![], - (vec![fuel.clone()], collateral_return, total_collateral), - fee, - administrators.clone(), - script_integrity_hash( - Some(&redeemers), - None, - &[(Language::PlutusV3, ¶ms.cost_model_v3[..])], - ) - .unwrap(), - ), - transaction_witness_set: new_witness_set(redeemers, validator.clone()), - success: true, - auxiliary_data: Nullable::Null, - } - }) -} - -#[allow(clippy::too_many_arguments)] -async fn vote( - network: Cardano, - validator: Bytes, - delegates: Vec>, - choice: Vote, - anchor: Option<&str>, - proposal_id: GovActionId, - OutputReference(contract): OutputReference, - OutputReference(fuel): OutputReference, -) -> Result { - let (validator_hash, _) = from_validator(validator.as_ref(), network.network_id()); - - let params = network.protocol_parameters().await; - - let contract_output = network - .resolve(&contract) - .await - .expect("failed to resolve contract UTxO"); - - let fuel_output = network - .resolve(&fuel) - .await - .expect("failed to resolve fuel UTxO"); - - let resolved_inputs = &[ - ResolvedInput { - input: contract.clone(), - output: PseudoTransactionOutput::PostAlonzo(contract_output.clone()), - }, - ResolvedInput { - input: fuel.clone(), - output: PseudoTransactionOutput::PostAlonzo(fuel_output.clone()), - }, - ]; - - let (rules, _) = recover_rules(&network, &validator_hash, &contract_output.value).await; - - let anchor = if let Some(url) = anchor { - let response = reqwest::get(url) - .await - .expect("failed to fetch anchor at URL: {url}"); - match response.status() { - status if status.is_success() => { - let content_hash = Hasher::<256>::hash(response.bytes().await.unwrap().as_ref()); - Some(Anchor { - url: url.to_string(), - content_hash, - }) - } - status => panic!("failed to fetch anchor content, server said: {status:?}"), - } - } else { - None - }; - - build_transaction(¶ms, resolved_inputs, |fee, ex_units| { - let mut redeemers = vec![]; - - let inputs = vec![fuel.clone()]; - - let reference_inputs = vec![contract.clone()]; - - let outputs = vec![ - // Change - PostAlonzoTransactionOutput { - address: fuel_output.address.clone(), - value: subtract(fuel_output.value.clone(), fee).expect("not enough fuel"), - datum_option: None, - script_ref: None, - }, - ]; - - let total_collateral = (fee as f64 * params.collateral_percent).ceil() as u64; - - let collateral_return = PostAlonzoTransactionOutput { - address: fuel_output.address.clone(), - value: subtract(fuel_output.value.clone(), total_collateral).expect("not enough fuel"), - datum_option: None, - script_ref: None, - }; - - let votes = vec![( - Voter::DRepScript(validator_hash), - NonEmptyKeyValuePairs::Def(vec![( - proposal_id.clone(), - VotingProcedure { - vote: choice.clone(), - anchor: anchor.clone().map(Nullable::Some).unwrap_or(Nullable::Null), - }, - )]), - )]; - redeemers.push(( - RedeemersKey { - tag: RedeemerTag::Vote, - index: 0, - }, - RedeemersValue { - data: rules.clone(), - ex_units: ex_units[0], - }, - )); - - // ----- Put it all together - let redeemers = NonEmptyKeyValuePairs::Def(redeemers); - Tx { - transaction_body: new_transaction_body( - network.network_id(), - inputs, - reference_inputs, - outputs, - None, - vec![], - votes, - (vec![fuel.clone()], collateral_return, total_collateral), - fee, - delegates.clone(), - script_integrity_hash( - Some(&redeemers), - None, - &[(Language::PlutusV3, ¶ms.cost_model_v3[..])], - ) - .unwrap(), - ), - transaction_witness_set: new_witness_set(redeemers, validator.clone()), - success: true, - auxiliary_data: Nullable::Null, - } - }) -} - -// Build a transaction by repeatedly executing some building logic with different fee and execution -// units settings. Stops when a fixed point is reached. The final transaction has corresponding -// fees and execution units. -fn build_transaction( - params: &ProtocolParameters, - resolved_inputs: &[ResolvedInput], - with: F, -) -> Result -where - F: Fn(u64, &[ExUnits]) -> Tx, -{ - let empty_ex_units = || { - vec![ - ExUnits { mem: 0, steps: 0 }, - ExUnits { mem: 0, steps: 0 }, - ExUnits { mem: 0, steps: 0 }, - ExUnits { mem: 0, steps: 0 }, - ] - }; - - let mut fee = 0; - let mut ex_units = empty_ex_units(); - - let mut tx; - let mut attempts = 0; - loop { - tx = with(fee, &ex_units[..]); - - // Convert to minted_tx... - let mut serialized_tx = Vec::new(); - cbor::encode(&tx, &mut serialized_tx).unwrap(); - - let mut calculated_ex_units = if resolved_inputs.is_empty() { - empty_ex_units() - } else { - // Compute execution units - let minted_tx = cbor::decode(&serialized_tx).unwrap(); - eval_phase_two( - &minted_tx, - resolved_inputs, - None, - None, - &SlotConfig::default(), - false, - |_| (), - ) - .expect("script evaluation failed") - .into_iter() - .map(|r| r.ex_units) - .collect::>() - }; - - calculated_ex_units.extend(empty_ex_units()); - - attempts += 1; - - let estimated_fee = { - // NOTE: This is a best effort to estimate the number of signatories since signatures - // will add an overhead to the fee. Yet, if inputs are locked by native scripts each - // requiring multiple signatories, this will unfortunately fall short. - // - // For similar reasons, it will also over-estimate fees by a small margin for every - // script-locked inputs that do not require signatories. - // - // This is however *acceptable* in our context. - let num_signatories = tx.transaction_body.inputs.len() - + tx.transaction_body - .required_signers - .as_ref() - .map(|xs| xs.len()) - .unwrap_or(0); - - params.fee_constant - + params.fee_coefficient - * (5 + ex_units.len() * 16 + num_signatories * 102 + serialized_tx.len()) as u64 - + total_execution_cost(params, &ex_units) - }; - - // Check if we've reached a fixed point, or start over. - if fee >= estimated_fee - && calculated_ex_units - .iter() - .zip(ex_units) - .all(|(l, r)| l.eq(&r)) - { - break; - } else if attempts >= 3 { - panic!("failed to build transaction: did not converge after three attempts."); - } else { - ex_units = calculated_ex_units; - fee = estimated_fee; - } - } - - Ok(tx) -} - -// ---------------------------------------------------------------- helpers ---- - -fn default_transaction_body() -> TransactionBody { - TransactionBody { - auxiliary_data_hash: None, - certificates: None, - collateral: None, - collateral_return: None, - donation: None, - fee: 0, - inputs: Set::from(vec![]), - mint: None, - network_id: None, - outputs: vec![], - proposal_procedures: None, - reference_inputs: None, - required_signers: None, - script_data_hash: None, - total_collateral: None, - treasury_value: None, - ttl: None, - validity_interval_start: None, - voting_procedures: None, - withdrawals: None, - } -} - -fn default_witness_set() -> WitnessSet { - WitnessSet { - bootstrap_witness: None, - native_script: None, - plutus_data: None, - plutus_v1_script: None, - plutus_v2_script: None, - plutus_v3_script: None, - redeemer: None, - vkeywitness: None, - } -} - -#[allow(clippy::too_many_arguments)] -fn new_transaction_body( - network_id: Network, - inputs: Vec, - reference_inputs: Vec, - outputs: Vec, - mint: Option>, - certificates: Vec, - votes: Vec<(Voter, NonEmptyKeyValuePairs)>, - (collateral, collateral_return, total_collateral): ( - Vec, - PostAlonzoTransactionOutput, - u64, - ), - fee: u64, - extra_signatories: Vec>, - script_data_hash: Hash<32>, -) -> TransactionBody { - TransactionBody { - inputs: Set::from(inputs), - reference_inputs: if reference_inputs.is_empty() { - None - } else { - Some(NonEmptySet::try_from(reference_inputs).unwrap()) - }, - outputs: outputs - .into_iter() - .map(PseudoTransactionOutput::PostAlonzo) - .collect(), - fee, - required_signers: NonEmptySet::try_from(extra_signatories).ok(), - mint, - certificates: if certificates.is_empty() { - None - } else { - Some(NonEmptySet::try_from(certificates).unwrap()) - }, - voting_procedures: if votes.is_empty() { - None - } else { - Some(NonEmptyKeyValuePairs::try_from(votes).unwrap()) - }, - collateral: Some(NonEmptySet::try_from(collateral).unwrap()), - collateral_return: Some(PseudoTransactionOutput::PostAlonzo(collateral_return)), - total_collateral: Some(total_collateral), - network_id: Some(match network_id { - Network::Mainnet => NetworkId::Two, - _ => NetworkId::One, - }), - script_data_hash: Some(script_data_hash), - ..default_transaction_body() - } -} - -fn new_witness_set( - redeemers: NonEmptyKeyValuePairs, - validator: Bytes, -) -> WitnessSet { - WitnessSet { - redeemer: Some(redeemers.into()), - plutus_v3_script: Some(NonEmptySet::try_from(vec![PlutusV3Script(validator)]).unwrap()), - ..default_witness_set() - } -} - -fn void() -> PlutusData { - PlutusData::Constr(Constr { - tag: 121, - any_constructor: None, - fields: vec![], - }) -} - -fn find_contract_token(value: &Value) -> Option { - match value { - Value::Multiasset(_, ref assets) => assets - .first() - .and_then(|(_, assets)| assets.first().cloned()), - _ => None, - } - .map(|pair| pair.0) -} - -fn build_rules(delegates: &[Hash<28>], quorum: usize) -> (PlutusData, AssetName) { - assert!( - quorum <= delegates.len(), - "quorum cannot be larger than number of delegates" - ); - - assert!(!delegates.is_empty(), "there must be at least one delegate"); - - let rules = PlutusData::Constr(Constr { - tag: 123, - any_constructor: None, - fields: vec![PlutusData::Array( - delegates - .iter() - .map(|delegate| { - PlutusData::Constr(Constr { - tag: 121, - any_constructor: None, - fields: vec![PlutusData::BoundedBytes( - delegate.as_slice().to_vec().into(), - )], - }) - }) - .collect::>(), - )], - }); - - let mut asset_name = "gov_".as_bytes().to_vec(); - asset_name.extend(Hasher::<224>::hash_cbor(&rules).as_slice()); - - (rules, asset_name.into()) -} - -// To avoid re-asking users for the delegates and quorum during vote (which is (1) inconvenient, -// and (2), utterly confusing with the existing delegates signatories...), we pull the rules from -// the minting transaction corresponding to the current state token. The token is always minted -// alongside a DRep registration certificate which defines the new rules as redeemer. -async fn recover_rules( - network: &Cardano, - validator_hash: &Hash<28>, - contract_value: &Value, -) -> (PlutusData, AssetName) { - let asset_name = find_contract_token(contract_value).expect("no state token in contract utxo?"); - - let minting_txs = network.minting(validator_hash, &asset_name).await; - - let minting_tx = minting_txs.first().unwrap_or_else(|| { - panic!( - "no minting transaction found for {}", - hex::encode(&asset_name[..]), - ) - }); - - let rules = if let Some(ref redeemers) = minting_tx.transaction_witness_set.redeemer { - redeemers - .iter() - .find_map(|(key, value)| { - if key.tag == RedeemerTag::Cert && value.data != void() { - Some(value.data.clone()) - } else { - None - } - }) - .expect("could not find registration certificate alongside minting transaction?!") - } else { - unreachable!() - }; - - (rules, asset_name) -} - -fn singleton_assets( - validator_hash: Hash<28>, - assets: &[(AssetName, T)], -) -> Multiasset { - NonEmptyKeyValuePairs::Def(vec![( - validator_hash, - NonEmptyKeyValuePairs::Def(assets.to_vec()), - )]) -} - -fn from_validator(validator: &[u8], network_id: Network) -> (Hash<28>, ShelleyAddress) { - let validator_hash = Hasher::<224>::hash_tagged(validator, 3); - let validator_address = ShelleyAddress::new( - network_id, - ShelleyPaymentPart::script_hash(validator_hash), - ShelleyDelegationPart::script_hash(validator_hash), - ); - - (validator_hash, validator_address) -} - -fn subtract(total_value: Value, total_cost: u64) -> Option { - match total_value { - Value::Coin(total) if total > total_cost => Some(Value::Coin(total - total_cost)), - Value::Multiasset(total, assets) if total > total_cost => { - Some(Value::Multiasset(total - total_cost, assets)) - } - _ => None, - } -} - -fn lovelace_of(value: &Value) -> u64 { - match value { - Value::Coin(lovelace) | Value::Multiasset(lovelace, _) => *lovelace, - } -} - -// Move to Pallas somewhere. -fn new_min_value_output(per_byte: u64, build: F) -> PostAlonzoTransactionOutput -where - F: Fn(u64) -> PostAlonzoTransactionOutput, -{ - let value = build(1); - let mut buffer: Vec = Vec::new(); - cbor::encode(&value, &mut buffer).unwrap(); - // NOTE: 160 overhead as per the spec + 4 bytes for actual final lovelace value. - // Technically, the final value could need 8 more additional bytes if the resulting - // value was larger than 4_294_967_295 lovelaces, which would realistically never be - // the case. - build((buffer.len() as u64 + 164) * per_byte) -} - -fn total_execution_cost(params: &ProtocolParameters, redeemers: &[ExUnits]) -> u64 { - redeemers.iter().fold(0, |acc, ex_units| { - acc + ((params.price_mem * ex_units.mem as f64).ceil() as u64) - + ((params.price_steps * ex_units.steps as f64).ceil() as u64) - }) -} - -fn script_integrity_hash( - redeemers: Option<&NonEmptyKeyValuePairs>, - datums: Option<&NonEmptyKeyValuePairs, PlutusData>>, - language_views: &[(Language, &[i64])], -) -> Option> { - if redeemers.is_none() && language_views.is_empty() && datums.is_none() { - return None; - } - - let mut preimage: Vec = Vec::new(); - if let Some(redeemers) = redeemers { - cbor::encode(redeemers, &mut preimage).unwrap(); - } - - if let Some(datums) = datums { - cbor::encode(datums, &mut preimage).unwrap(); - } - - // NOTE: This doesn't work for PlutusV1, but I don't care. - if !language_views.is_empty() { - let mut views = language_views.to_vec(); - // TODO: Derive an Ord instance in Pallas. - views.sort_by(|(a, _), (b, _)| match (a, b) { - (Language::PlutusV3, Language::PlutusV3) => Ordering::Equal, - (Language::PlutusV3, _) => Ordering::Greater, - (_, Language::PlutusV3) => Ordering::Less, - - (Language::PlutusV2, Language::PlutusV2) => Ordering::Equal, - (Language::PlutusV2, _) => Ordering::Greater, - (_, Language::PlutusV2) => Ordering::Less, - - (Language::PlutusV1, Language::PlutusV1) => Ordering::Equal, - }); - cbor::encode(NonEmptyKeyValuePairs::Def(views), &mut preimage).unwrap() - } - - Some(Hasher::<256>::hash(&preimage)) -} diff --git a/cli/src/pallas_extra.rs b/cli/src/pallas_extra.rs new file mode 100644 index 0000000..ea193cc --- /dev/null +++ b/cli/src/pallas_extra.rs @@ -0,0 +1,314 @@ +use std::{cmp::Ordering, str::FromStr}; +use uplc::tx::{eval_phase_two, ResolvedInput, SlotConfig}; + +use pallas_addresses::{Network, ShelleyAddress, ShelleyDelegationPart, ShelleyPaymentPart}; +use pallas_codec::{ + minicbor as cbor, + utils::{NonEmptyKeyValuePairs, NonEmptySet, Set}, +}; +use pallas_crypto::hash::{Hash, Hasher}; +use pallas_primitives::conway::{ + AssetName, Constr, ExUnits, Language, Multiasset, NetworkId, PlutusData, + PostAlonzoTransactionOutput, PseudoTransactionOutput, RedeemersKey, RedeemersValue, + TransactionBody, TransactionInput, TransactionOutput, Tx, Value, WitnessSet, +}; + +pub struct OutputReference(pub TransactionInput); + +impl FromStr for OutputReference { + type Err = String; + fn from_str(s: &str) -> Result { + match &s.split('#').collect::>()[..] { + [tx_id_str, ix_str] => { + let transaction_id: Hash<32> = tx_id_str + .parse() + .map_err(|e| format!("failed to decode transaction id from hex: {e:?}"))?; + let index: u64 = ix_str + .parse() + .map_err(|e| format!("failed to decode output index: {e:?}"))?; + Ok(OutputReference(TransactionInput { + transaction_id, + index, + })) + } + _ => Err("malformed output reference: expected a hex-encode string and an index separated by '#'".to_string()), + } + } +} + +pub fn void() -> PlutusData { + PlutusData::Constr(Constr { + tag: 121, + any_constructor: None, + fields: vec![], + }) +} + +pub fn from_network(network: Network) -> NetworkId { + match network { + Network::Mainnet => NetworkId::Two, + _ => NetworkId::One, + } +} + +pub fn non_empty_set(set: Vec) -> Option> +where + T: std::fmt::Debug, +{ + if set.is_empty() { + None + } else { + Some(NonEmptySet::try_from(set).unwrap()) + } +} + +pub fn non_empty_pairs(pairs: Vec<(K, V)>) -> Option> +where + V: Clone, + K: Clone, +{ + if pairs.is_empty() { + None + } else { + Some(NonEmptyKeyValuePairs::Def(pairs)) + } +} + +pub fn into_outputs(outputs: Vec) -> Vec { + outputs + .into_iter() + .map(PseudoTransactionOutput::PostAlonzo) + .collect() +} + +pub fn singleton_assets( + validator_hash: Hash<28>, + assets: &[(AssetName, T)], +) -> Multiasset { + NonEmptyKeyValuePairs::Def(vec![( + validator_hash, + NonEmptyKeyValuePairs::Def(assets.to_vec()), + )]) +} + +pub fn from_validator(validator: &[u8], network_id: Network) -> (Hash<28>, ShelleyAddress) { + let validator_hash = Hasher::<224>::hash_tagged(validator, 3); + let validator_address = ShelleyAddress::new( + network_id, + ShelleyPaymentPart::script_hash(validator_hash), + ShelleyDelegationPart::script_hash(validator_hash), + ); + + (validator_hash, validator_address) +} + +pub fn subtract(total_value: Value, total_cost: u64) -> Option { + match total_value { + Value::Coin(total) if total > total_cost => Some(Value::Coin(total - total_cost)), + Value::Multiasset(total, assets) if total > total_cost => { + Some(Value::Multiasset(total - total_cost, assets)) + } + _ => None, + } +} + +pub fn lovelace_of(value: &Value) -> u64 { + match value { + Value::Coin(lovelace) | Value::Multiasset(lovelace, _) => *lovelace, + } +} + +pub fn new_min_value_output(per_byte: u64, build: F) -> PostAlonzoTransactionOutput +where + F: Fn(u64) -> PostAlonzoTransactionOutput, +{ + let value = build(1); + let mut buffer: Vec = Vec::new(); + cbor::encode(&value, &mut buffer).unwrap(); + // NOTE: 160 overhead as per the spec + 4 bytes for actual final lovelace value. + // Technically, the final value could need 8 more additional bytes if the resulting + // value was larger than 4_294_967_295 lovelaces, which would realistically never be + // the case. + build((buffer.len() as u64 + 164) * per_byte) +} + +pub fn total_execution_cost((price_mem, price_steps): (f64, f64), redeemers: &[ExUnits]) -> u64 { + redeemers.iter().fold(0, |acc, ex_units| { + acc + ((price_mem * ex_units.mem as f64).ceil() as u64) + + ((price_steps * ex_units.steps as f64).ceil() as u64) + }) +} + +pub fn script_integrity_hash( + redeemers: Option<&NonEmptyKeyValuePairs>, + datums: Option<&NonEmptyKeyValuePairs, PlutusData>>, + language_views: &[(Language, &[i64])], +) -> Option> { + if redeemers.is_none() && language_views.is_empty() && datums.is_none() { + return None; + } + + let mut preimage: Vec = Vec::new(); + if let Some(redeemers) = redeemers { + cbor::encode(redeemers, &mut preimage).unwrap(); + } + + if let Some(datums) = datums { + cbor::encode(datums, &mut preimage).unwrap(); + } + + // NOTE: This doesn't work for PlutusV1, but I don't care. + if !language_views.is_empty() { + let mut views = language_views.to_vec(); + // TODO: Derive an Ord instance in Pallas. + views.sort_by(|(a, _), (b, _)| match (a, b) { + (Language::PlutusV3, Language::PlutusV3) => Ordering::Equal, + (Language::PlutusV3, _) => Ordering::Greater, + (_, Language::PlutusV3) => Ordering::Less, + + (Language::PlutusV2, Language::PlutusV2) => Ordering::Equal, + (Language::PlutusV2, _) => Ordering::Greater, + (_, Language::PlutusV2) => Ordering::Less, + + (Language::PlutusV1, Language::PlutusV1) => Ordering::Equal, + }); + cbor::encode(NonEmptyKeyValuePairs::Def(views), &mut preimage).unwrap() + } + + Some(Hasher::<256>::hash(&preimage)) +} + +pub fn default_transaction_body() -> TransactionBody { + TransactionBody { + auxiliary_data_hash: None, + certificates: None, + collateral: None, + collateral_return: None, + donation: None, + fee: 0, + inputs: Set::from(vec![]), + mint: None, + network_id: None, + outputs: vec![], + proposal_procedures: None, + reference_inputs: None, + required_signers: None, + script_data_hash: None, + total_collateral: None, + treasury_value: None, + ttl: None, + validity_interval_start: None, + voting_procedures: None, + withdrawals: None, + } +} + +pub fn default_witness_set() -> WitnessSet { + WitnessSet { + bootstrap_witness: None, + native_script: None, + plutus_data: None, + plutus_v1_script: None, + plutus_v2_script: None, + plutus_v3_script: None, + redeemer: None, + vkeywitness: None, + } +} + +// Build a transaction by repeatedly executing some building logic with different fee and execution +// units settings. Stops when a fixed point is reached. The final transaction has corresponding +// fees and execution units. +pub fn build_transaction( + (fee_constant, fee_coefficient, prices): (u64, u64, (f64, f64)), + resolved_inputs: &[ResolvedInput], + with: F, +) -> Tx +where + F: Fn(u64, &[ExUnits]) -> Tx, +{ + let empty_ex_units = || { + vec![ + ExUnits { mem: 0, steps: 0 }, + ExUnits { mem: 0, steps: 0 }, + ExUnits { mem: 0, steps: 0 }, + ExUnits { mem: 0, steps: 0 }, + ] + }; + + let mut fee = 0; + let mut ex_units = empty_ex_units(); + + let mut tx; + let mut attempts = 0; + loop { + tx = with(fee, &ex_units[..]); + + // Convert to minted_tx... + let mut serialized_tx = Vec::new(); + cbor::encode(&tx, &mut serialized_tx).unwrap(); + + let mut calculated_ex_units = if resolved_inputs.is_empty() { + empty_ex_units() + } else { + // Compute execution units + let minted_tx = cbor::decode(&serialized_tx).unwrap(); + eval_phase_two( + &minted_tx, + resolved_inputs, + None, + None, + &SlotConfig::default(), + false, + |_| (), + ) + .expect("script evaluation failed") + .into_iter() + .map(|r| r.ex_units) + .collect::>() + }; + + calculated_ex_units.extend(empty_ex_units()); + + attempts += 1; + + let estimated_fee = { + // NOTE: This is a best effort to estimate the number of signatories since signatures + // will add an overhead to the fee. Yet, if inputs are locked by native scripts each + // requiring multiple signatories, this will unfortunately fall short. + // + // For similar reasons, it will also over-estimate fees by a small margin for every + // script-locked inputs that do not require signatories. + // + // This is however *acceptable* in our context. + let num_signatories = tx.transaction_body.inputs.len() + + tx.transaction_body + .required_signers + .as_ref() + .map(|xs| xs.len()) + .unwrap_or(0); + + fee_constant + + fee_coefficient + * (5 + ex_units.len() * 16 + num_signatories * 102 + serialized_tx.len()) as u64 + + total_execution_cost(prices, &ex_units) + }; + + // Check if we've reached a fixed point, or start over. + if fee >= estimated_fee + && calculated_ex_units + .iter() + .zip(ex_units) + .all(|(l, r)| l.eq(&r)) + { + break; + } else if attempts >= 3 { + panic!("failed to build transaction: did not converge after three attempts."); + } else { + ex_units = calculated_ex_units; + fee = estimated_fee; + } + } + + tx +}