Skip to main content

seedelf_core/
utxos.rs

1use crate::assets::{Asset, Assets, string_to_u64};
2use crate::constants::{MAXIMUM_TOKENS_PER_UTXO, MAXIMUM_WALLET_UTXOS};
3use crate::transaction::wallet_minimum_lovelace_with_assets;
4use anyhow::{Context, Ok, Result, anyhow, bail};
5use blstrs::Scalar;
6use hex;
7use seedelf_crypto::register::Register;
8use seedelf_koios::koios::{
9    ProtocolParameters, UtxoResponse, address_utxos, contains_policy_id, credential_utxos,
10    extract_bytes_with_logging,
11};
12
13pub async fn get_credential_utxos(
14    wallet_contract_hash: [u8; 28],
15    network_flag: bool,
16) -> Result<Vec<UtxoResponse>> {
17    let utxos: Vec<UtxoResponse> =
18        credential_utxos(hex::encode(wallet_contract_hash).as_str(), network_flag)
19            .await
20            .context("Failed To Get Credential UTxOs")?;
21    Ok(utxos)
22}
23
24pub async fn get_address_utxos(address: &str, network_flag: bool) -> Result<Vec<UtxoResponse>> {
25    let utxos: Vec<UtxoResponse> = address_utxos(address, network_flag)
26        .await
27        .context("Failed To Get Address UTxOs")?;
28    Ok(utxos)
29}
30
31/// collects all the wallet utxos owned by some scalar.
32pub fn collect_all_wallet_utxos(
33    sk: Scalar,
34    seedelf_policy_id: &str,
35    utxos: Vec<UtxoResponse>,
36) -> Result<Vec<UtxoResponse>> {
37    let mut all_utxos: Vec<UtxoResponse> = Vec::new();
38    for utxo in utxos {
39        if let Some(inline_datum) = extract_bytes_with_logging(&utxo.inline_datum) {
40            // utxo must be owned by this secret scalar. A register whose
41            // points don't decompress is nobody's: anyone can pay the
42            // contract under one, so it's skipped, never an error that
43            // stops the scan.
44            if inline_datum.is_owned(sk).unwrap_or(false) {
45                // its owned but lets not count the seedelf in the balance
46                if !contains_policy_id(&utxo.asset_list, seedelf_policy_id) {
47                    all_utxos.push(utxo.clone());
48                }
49            }
50        }
51    }
52    Ok(all_utxos)
53}
54
55pub fn find_seedelf_datum(
56    seedelf: String,
57    seedelf_policy_id: &str,
58    utxos: Vec<UtxoResponse>,
59) -> Result<Option<Register>> {
60    let mut seedelf_datum: Option<Register> = None;
61
62    for utxo in utxos {
63        // Extract bytes
64        if let Some(inline_datum) = extract_bytes_with_logging(&utxo.inline_datum)
65            && contains_policy_id(&utxo.asset_list, seedelf_policy_id)
66        {
67            let asset_name = utxo
68                .asset_list
69                .as_ref()
70                .and_then(|vec| {
71                    vec.iter()
72                        .find(|asset| asset.policy_id == seedelf_policy_id)
73                        .map(|asset| &asset.asset_name)
74                })
75                .context("Can't Produce Asset Name")?;
76            if asset_name == &seedelf {
77                seedelf_datum = Some(inline_datum.clone());
78            }
79        }
80    }
81    Ok(seedelf_datum)
82}
83
84/// Find a specific seedelf.
85pub fn find_seedelf_utxo(
86    seedelf: String,
87    seedelf_policy_id: &str,
88    utxos: Vec<UtxoResponse>,
89) -> Result<Option<UtxoResponse>> {
90    for utxo in utxos {
91        if contains_policy_id(&utxo.asset_list, seedelf_policy_id) {
92            let asset_name = utxo
93                .asset_list
94                .as_ref()
95                .and_then(|vec| {
96                    vec.iter()
97                        .find(|asset| asset.policy_id == seedelf_policy_id)
98                        .map(|asset| &asset.asset_name)
99                })
100                .context("Can't Produce Asset Name")?;
101            if asset_name == &seedelf {
102                // we found it so stop searching
103                return Ok(Some(utxo));
104            }
105        }
106    }
107    Ok(None)
108}
109
110// Find wallet utxos owned by some scalar. The maximum amount of utxos is limited by a upper bound.
111pub fn collect_wallet_utxos(
112    sk: Scalar,
113    seedelf_policy_id: &str,
114    utxos: Vec<UtxoResponse>,
115) -> Result<Vec<UtxoResponse>> {
116    let mut number_of_utxos: u64 = 0;
117    let mut usable_utxos: Vec<UtxoResponse> = Vec::new();
118
119    for utxo in utxos {
120        // Extract bytes
121        if let Some(inline_datum) = extract_bytes_with_logging(&utxo.inline_datum) {
122            // utxo must be owned by this secret scalar. A register whose
123            // points don't decompress is nobody's, and doesn't stop the scan.
124            if inline_datum.is_owned(sk).unwrap_or(false) {
125                // its owned but it can't hold a seedelf
126                if !contains_policy_id(&utxo.asset_list, seedelf_policy_id) {
127                    if number_of_utxos >= MAXIMUM_WALLET_UTXOS {
128                        // we hit the max utxos allowed in a single tx
129                        break;
130                    }
131                    usable_utxos.push(utxo);
132                    number_of_utxos += 1;
133                }
134            }
135        }
136    }
137    Ok(usable_utxos)
138}
139
140/// Collect all the address utxos that are not an assumed collateral utxo.
141///
142/// Koios reports a token-less outpoint as either `Some(vec![])` or `None`; treat
143/// them identically. An earlier version filtered on `Some(_)` and silently
144/// dropped every pure-ADA UTxO whose `asset_list` came back `None`.
145pub fn collect_address_utxos(utxos: Vec<UtxoResponse>) -> Result<Vec<UtxoResponse>> {
146    let mut usable_utxos: Vec<UtxoResponse> = Vec::new();
147    for utxo in utxos {
148        let lovelace: u64 = utxo.value.parse::<u64>().context("Invalid Lovelace")?;
149        let is_empty_asset_list = utxo
150            .asset_list
151            .as_ref()
152            .is_none_or(|assets| assets.is_empty());
153        if is_empty_asset_list && lovelace == 5_000_000 {
154            // probably the collateral UTxO — skip
155            continue;
156        }
157        usable_utxos.push(utxo);
158    }
159    Ok(usable_utxos)
160}
161
162// lets assume that the lovelace here initially accounts for the estimated fee, like 1 ada or something
163// use largest first algo but account for change
164pub fn select(
165    params: &ProtocolParameters,
166    utxos: Vec<UtxoResponse>,
167    lovelace: u64,
168    tokens: Assets,
169) -> Result<Vec<UtxoResponse>> {
170    do_select(params, utxos, lovelace, tokens, lovelace).context("Do Select Failed")
171}
172fn do_select(
173    params: &ProtocolParameters,
174    utxos: Vec<UtxoResponse>,
175    lovelace: u64,
176    tokens: Assets,
177    lovelace_goal: u64,
178) -> Result<Vec<UtxoResponse>> {
179    let mut selected_utxos: Vec<UtxoResponse> = Vec::new();
180
181    let mut current_lovelace_sum: u64 = 0;
182    let mut found_enough: bool = false;
183
184    // all the found assets
185    let mut found_assets: Assets = Assets::new();
186
187    // Sort: no-token UTxOs first, then by descending lovelace value within
188    // each group. Koios reports a token-less outpoint as either `Some(vec![])`
189    // or `None`; both must group together.
190    //
191    // Pre-parse the values once and bail on any malformed entry. An earlier
192    // version used `Result::into_iter().cmp(...)`, which silently treated
193    // parse failures as `Equal` and could break the largest-first invariant
194    // the selector below depends on.
195    let mut sortable: Vec<(u64, UtxoResponse)> = utxos
196        .into_iter()
197        .map(|u| -> Result<(u64, UtxoResponse)> {
198            let v = string_to_u64(u.value.clone()).context("Invalid UTxO Value")?;
199            Ok((v, u))
200        })
201        .collect::<Result<Vec<_>>>()?;
202    sortable.sort_by(|(a_val, a), (b_val, b)| {
203        let a_has_tokens = a.asset_list.as_ref().is_some_and(|list| !list.is_empty());
204        let b_has_tokens = b.asset_list.as_ref().is_some_and(|list| !list.is_empty());
205        a_has_tokens
206            .cmp(&b_has_tokens)
207            .then_with(|| b_val.cmp(a_val))
208    });
209    let utxos: Vec<UtxoResponse> = sortable.into_iter().map(|(_, u)| u).collect();
210
211    for utxo in utxos.clone() {
212        // the value from koios is the lovelace
213        let value: u64 = string_to_u64(utxo.value.clone()).context("Invalid UTxO Value")?;
214
215        let mut utxo_assets: Assets = Assets::new();
216        let mut added: bool = false;
217
218        // Treat `None` and `Some(vec![])` identically — both mean "no native
219        // tokens here". An earlier version of this branch dropped `None`
220        // through entirely, leaving such UTxOs unselectable until the
221        // tokens-already-found path fired.
222        let asset_list: Vec<seedelf_koios::koios::Asset> =
223            utxo.asset_list.clone().unwrap_or_default();
224        if !asset_list.is_empty() {
225            for token in asset_list {
226                utxo_assets = utxo_assets
227                    .add(
228                        Asset::new(
229                            token.policy_id,
230                            token.asset_name,
231                            string_to_u64(token.quantity).context("Invalid Asset Amount")?,
232                        )
233                        .context("Invalid Asset")?,
234                    )
235                    .context("Can't Add Assets")?;
236            }
237            // if this utxo has the assets we need but we haven't found it all yet then add it
238            if utxo_assets.any(tokens.clone()) && !found_assets.contains(tokens.clone()) {
239                selected_utxos.push(utxo.clone());
240                current_lovelace_sum = current_lovelace_sum
241                    .checked_add(value)
242                    .context("Lovelace sum overflow")?;
243                found_assets = found_assets
244                    .merge(utxo_assets.clone())
245                    .context("Can't Merge Assets")?;
246                added = true;
247            }
248        } else if current_lovelace_sum < lovelace {
249            // no tokens here just lovelace so add it
250            selected_utxos.push(utxo.clone());
251            current_lovelace_sum = current_lovelace_sum
252                .checked_add(value)
253                .context("Lovelace sum overflow")?;
254            added = true;
255        }
256
257        // the utxo is not pure ada and doesnt contain what you need but you need ada because you already found the tokens so add it
258        if !added && current_lovelace_sum < lovelace && found_assets.contains(tokens.clone()) {
259            selected_utxos.push(utxo.clone());
260            current_lovelace_sum = current_lovelace_sum
261                .checked_add(value)
262                .context("Lovelace sum overflow")?;
263            found_assets = found_assets
264                .merge(utxo_assets)
265                .context("Can't Merge Assets")?;
266        }
267
268        // we know we found enough lovelace and assets
269        if current_lovelace_sum >= lovelace && found_assets.contains(tokens.clone()) {
270            // but is it enough to account for the min ada for the token change as we will assume there will always be a change utxo
271            let change_assets: Assets = found_assets
272                .separate(tokens.clone())
273                .context("Can't Separate Assets")?;
274            let number_of_change_assets: u64 = change_assets.len();
275            let minimum: u64 = wallet_minimum_lovelace_with_assets(params, change_assets.clone())
276                .context("Invalid Minimum Lovelace")?;
277            // we need to calculate how many multiple change utxos we need
278            let multiplier: u64 = if number_of_change_assets > MAXIMUM_TOKENS_PER_UTXO {
279                // add one due to floor division
280                (number_of_change_assets / MAXIMUM_TOKENS_PER_UTXO) + 1
281            } else {
282                1
283            };
284            // we need lovelace for both the spend goal AND the change min-UTxO.
285            // Compare via addition instead of subtraction — degenerate token
286            // UTxOs (lots of accessory tokens, little ADA) can make the change
287            // floor exceed gathered lovelace, which would underflow u64.
288            let change_floor: u64 = multiplier.saturating_mul(minimum);
289            let total_needed: u64 = lovelace_goal.saturating_add(change_floor);
290            if current_lovelace_sum >= total_needed {
291                found_enough = true;
292                break;
293            } else {
294                // not yet — bump the lovelace target by the change floor and retry
295                return do_select(
296                    params,
297                    utxos.clone(),
298                    lovelace.saturating_add(change_floor),
299                    tokens.clone(),
300                    lovelace_goal,
301                );
302            }
303        }
304    }
305    if found_enough {
306        // we found enough utxos to pay for it
307        Ok(selected_utxos)
308    } else {
309        // not enough utxos to pay for what you are trying to do so return the empty utxo set
310        Ok(Vec::new())
311    }
312}
313
314/// Calculate the total assets of a list of utxos.
315pub fn assets_of(utxos: Vec<UtxoResponse>) -> Result<(u64, Assets)> {
316    let mut found_assets: Assets = Assets::new();
317    let mut current_lovelace_sum: u64 = 0;
318
319    for utxo in utxos.clone() {
320        let value: u64 = string_to_u64(utxo.value.clone()).context("Invalid UTxO Value")?;
321        current_lovelace_sum = current_lovelace_sum
322            .checked_add(value)
323            .context("Lovelace sum overflow")?;
324
325        if let Some(assets) = utxo.clone().asset_list
326            && !assets.is_empty()
327        {
328            let mut utxo_assets: Assets = Assets::new();
329
330            for token in assets.clone() {
331                let new_asset = Asset::new(
332                    token.policy_id,
333                    token.asset_name,
334                    string_to_u64(token.quantity).context("Invalid Token Quantity")?,
335                )
336                .context("Fail To Construct Asset")?;
337                utxo_assets = utxo_assets.add(new_asset).context("Can't Add Assets")?;
338            }
339
340            found_assets = found_assets
341                .merge(utxo_assets.clone())
342                .context("Can't Merge Assets")?;
343        }
344    }
345    Ok((current_lovelace_sum, found_assets))
346}
347
348/// The size of a UTxO's reference script, as the ledger counts it for the
349/// reference-script fee (`build::reference_script_fee`): its bytes as Koios
350/// gives them, which must agree with Koios's `size` when it gives one. 0 when
351/// it holds none. One it holds but that can't be sized (no bytes, not hex, or
352/// a size that disagrees) is an error naming the UTxO, never 0: the fee
353/// would come out short, and the network would refuse the transaction.
354pub fn reference_script_size(utxo: &UtxoResponse) -> Result<u64> {
355    let Some(script) = &utxo.reference_script else {
356        return Ok(0);
357    };
358    let size = script
359        .bytes
360        .as_deref()
361        .and_then(|bytes| hex::decode(bytes).ok())
362        .map(|bytes| bytes.len() as u64)
363        .filter(|size| *size > 0 && script.size.is_none_or(|given| given == *size));
364    size.with_context(|| {
365        format!(
366            "UTxO {}#{} holds a reference script the wallet can't measure, so it can't price spending it",
367            utxo.tx_hash, utxo.tx_index
368        )
369    })
370}
371
372/// The reference-script bytes of `utxos` together (see [`reference_script_size`]).
373pub fn reference_script_bytes(utxos: &[UtxoResponse]) -> Result<u64> {
374    utxos.iter().try_fold(0u64, |total, utxo| {
375        Ok(total.saturating_add(reference_script_size(utxo)?))
376    })
377}
378
379/// Which of `utxos` one transaction can spend together (`taken`), and which
380/// it leaves out (`left`), each in the order given.
381///
382/// - A UTxO is left out when its tokens, added to `base`'s and to those of
383///   the UTxOs taken before it, would push a token's total past a u64. No
384///   output can hold more of one token than that, so totalling in u128
385///   wouldn't help: anyone can send three UTxOs of 2^63 − 1 of one token to
386///   an address, and nothing holding all three could be built.
387/// - The order they're taken in: ADA-only first (they never conflict), then
388///   those `first` picks (a session's own), then the most lovelace first,
389///   ties by outpoint, so the same UTxOs split the same way every time.
390/// - Every UTxO fits on its own, so spending again from `left` always takes
391///   at least one.
392pub fn fitting(
393    utxos: &[UtxoResponse],
394    base: &Assets,
395    first: impl Fn(&UtxoResponse) -> bool,
396) -> Result<(Vec<UtxoResponse>, Vec<UtxoResponse>)> {
397    let mut order: Vec<(usize, &UtxoResponse, u64, Assets)> = utxos
398        .iter()
399        .enumerate()
400        .map(|(i, utxo)| {
401            let (lovelace, tokens) = assets_of(vec![utxo.clone()])?;
402            Ok((i, utxo, lovelace, tokens))
403        })
404        .collect::<Result<_>>()?;
405    order.sort_by(
406        |(_, a, a_lovelace, a_tokens), (_, b, b_lovelace, b_tokens)| {
407            (
408                !a_tokens.is_empty(),
409                !first(a),
410                std::cmp::Reverse(*a_lovelace),
411            )
412                .cmp(&(
413                    !b_tokens.is_empty(),
414                    !first(b),
415                    std::cmp::Reverse(*b_lovelace),
416                ))
417                .then_with(|| (&a.tx_hash, a.tx_index).cmp(&(&b.tx_hash, b.tx_index)))
418        },
419    );
420    let mut total = base.clone();
421    let mut fits = vec![false; utxos.len()];
422    for (i, _, _, tokens) in order {
423        if let std::result::Result::Ok(more) = total.merge(tokens) {
424            total = more;
425            fits[i] = true;
426        }
427    }
428    let (taken, left): (Vec<_>, Vec<_>) = utxos.iter().zip(fits).partition(|(_, fits)| *fits);
429    Ok((
430        taken.into_iter().map(|(u, _)| u.clone()).collect(),
431        left.into_iter().map(|(u, _)| u.clone()).collect(),
432    ))
433}
434
435/// Find a seedelf that contains the label and print the match.
436pub fn find_all_seedelfs(
437    label: String,
438    seedelf_policy_id: &str,
439    utxos: Vec<UtxoResponse>,
440) -> Result<Vec<String>> {
441    let mut matches = Vec::new();
442    for utxo in utxos {
443        if contains_policy_id(&utxo.asset_list, seedelf_policy_id) {
444            let asset_name = utxo
445                .asset_list
446                .as_ref()
447                .and_then(|vec| {
448                    vec.iter()
449                        .find(|asset| asset.policy_id == seedelf_policy_id)
450                        .map(|asset| &asset.asset_name)
451                })
452                .context("Can't Produce Asset Name")?;
453            if asset_name.to_lowercase().contains(&label.to_lowercase()) {
454                // we found it so print it
455                matches.push(asset_name.to_string());
456            }
457        }
458    }
459    Ok(matches)
460}
461
462/// Find a seedelf that contains the label and print the match.
463pub fn count_lovelace_and_utxos(
464    seedelf_policy_id: &str,
465    utxos: Vec<UtxoResponse>,
466) -> Result<(usize, u64, u64)> {
467    let mut total_lovelace: u64 = 0;
468    let mut total_seedelfs: u64 = 0;
469
470    for utxo in utxos.clone() {
471        // count if a utxo holds a seedelf policy id
472        if contains_policy_id(&utxo.asset_list, seedelf_policy_id) {
473            total_seedelfs += 1;
474        }
475        // count the lovelace on the utxo
476        let value: u64 =
477            string_to_u64(utxo.value.clone()).context("failed to parse lovelace value")?;
478        total_lovelace += value;
479    }
480    Ok((utxos.len(), total_lovelace, total_seedelfs))
481}
482
483pub fn parse_tx_utxos(utxos: Vec<String>) -> Result<Vec<(String, u64)>> {
484    utxos
485        .into_iter()
486        .map(|s| {
487            let parts: Vec<&str> = s.split('#').collect();
488            if parts.len() != 2 {
489                bail!("Invalid input format: {s}");
490            }
491
492            let tx_hash = parts[0].to_string();
493            let index = parts[1]
494                .parse::<u64>()
495                .map_err(|_| anyhow!("Invalid index in input: {s}"))?;
496
497            Ok((tx_hash, index))
498        })
499        .collect()
500}
501
502pub fn filter_utxos(utxos: Vec<UtxoResponse>, targets: Vec<(String, u64)>) -> Vec<UtxoResponse> {
503    // For fast lookup, convert to a HashSet
504    use std::collections::HashSet;
505
506    let target_set: HashSet<(String, u64)> = targets.into_iter().collect();
507
508    utxos
509        .into_iter()
510        .filter(|u| target_set.contains(&(u.tx_hash.clone(), u.tx_index)))
511        .collect()
512}