• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

tari-project / tari / 19468834672

18 Nov 2025 02:01PM UTC coverage: 51.294% (-0.3%) from 51.544%
19468834672

push

github

web-flow
feat: add coin selection and spending via bins or buckets (#7584)

Description
---

Add range limit coin-join:
- Added an unspent output coin distribution gRPC method
('CoinHistogramRequest'), whereby the wallet will return the amount and
value of coins in a pre-set range of buckets.
- Added a range limit coin-join gRPC method ('RangeLimitCoinJoin') to
the wallet, whereby the user can specify the minimum target amount,
maximum number of inputs, dust lower bound (inclusive), dust upper bound
(exclusive) and fee. Transaction size will be limited to the specified
maximum number of inputs, and multiple outputs will be created according
to the minimum target amount. All the inputs in the range will be spent,
unless the total available amount does not meet the minimum target
amount.

Closes #7582.

Motivation and Context
---
See #7582.

How Has This Been Tested?
---
System-level testing

gRPC **CoinHistogram** method
```
{
  "buckets": [
    {
      "count": "0",
      "total_amount": "0",
      "lower_bound": "0",
      "upper_bound": "1000"
    },
    {
      "count": "0",
      "total_amount": "0",
      "lower_bound": "1000",
      "upper_bound": "100000"
    },
    {
      "count": "2",
      "total_amount": "1165548",
      "lower_bound": "100000",
      "upper_bound": "1000000"
    },
    {
      "count": "158",
      "total_amount": "1455989209",
      "lower_bound": "1000000",
      "upper_bound": "1000000000"
    },
    {
      "count": "0",
      "total_amount": "0",
      "lower_bound": "1000000000",
      "upper_bound": "100000000000"
    },
    {
      "count": "0",
      "total_amount": "0",
      "lower_bound": "100000000000",
      "upper_bound": "21000000000000000"
    }
  ]
}
```

gRPC **RangeLimitCoinJoin** method

In this example, two transactions were created, bounded by the 350 input
size limit. gRPC client view:

<img width="897" height="396" alt="image"
src="https://github.com/user-attachments/assets/6c5ae857-8a01-4c90-9c55-1eee2fbd... (continued)

0 of 636 new or added lines in 11 files covered. (0.0%)

17 existing lines in 8 files now uncovered.

59180 of 115373 relevant lines covered (51.29%)

7948.46 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

7.93
/base_layer/wallet/src/output_manager_service/storage/sqlite_db/output_sql.rs
1
//  Copyright 2021. The Tari Project
2
//
3
//  Redistribution and use in source and binary forms, with or without modification, are permitted provided that the
4
//  following conditions are met:
5
//
6
//  1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following
7
//  disclaimer.
8
//
9
//  2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the
10
//  following disclaimer in the documentation and/or other materials provided with the distribution.
11
//
12
//  3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote
13
//  products derived from this software without specific prior written permission.
14
//
15
//  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
16
//  INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
17
//  DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
18
//  SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
19
//  SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
20
//  WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
21
//  USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
22

23
use std::{
24
    convert::{TryFrom, TryInto},
25
    ops::Range,
26
    str::FromStr,
27
};
28

29
use borsh::BorshDeserialize;
30
use chrono::NaiveDateTime;
31
use derivative::Derivative;
32
use diesel::{
33
    dsl::{count_star, sql},
34
    prelude::*,
35
    sql_query,
36
    sql_types::{BigInt, Nullable},
37
};
38
use log::*;
39
use tari_common_sqlite::util::diesel_ext::ExpectedRowsExtension;
40
use tari_common_types::{
41
    transaction::TxId,
42
    types::{
43
        ComAndPubSignature,
44
        CompressedCommitment,
45
        CompressedPublicKey,
46
        FixedHash,
47
        HashOutput,
48
        PrivateKey,
49
        RangeProof,
50
    },
51
};
52
use tari_crypto::tari_utilities::ByteArray;
53
use tari_script::{ExecutionStack, TariScript};
54
use tari_transaction_components::{
55
    key_manager::TariKeyId,
56
    transaction_components::{
57
        EncryptedData,
58
        MemoField,
59
        OutputFeatures,
60
        OutputType,
61
        TransactionOutputVersion,
62
        WalletOutput,
63
    },
64
    MicroMinotari,
65
};
66
use tari_utilities::hex::Hex;
67

68
use crate::{
69
    output_manager_service::{
70
        error::OutputManagerStorageError,
71
        input_selection::{UtxoSelectionCriteria, UtxoSelectionMode},
72
        service::Balance,
73
        storage::{
74
            database::{OutputBackendQuery, SortDirection},
75
            models::{DbWalletOutput, SpendingPriority},
76
            sqlite_db::{CoinBucket, UpdateOutput, UpdateOutputSql},
77
            OutputSource,
78
            OutputStatus,
79
        },
80
        UtxoSelectionFilter,
81
        UtxoSelectionOrdering,
82
        TRANSACTION_INPUTS_LIMIT,
83
    },
84
    schema::outputs,
85
};
86

87
const LOG_TARGET: &str = "wallet::output_manager_service::database::wallet";
88

89
#[derive(Clone, Derivative, Debug, Queryable, Identifiable, PartialEq, QueryableByName)]
×
90
#[diesel(table_name = outputs)]
91
pub struct OutputSql {
92
    pub id: i32, // Auto inc primary key
93
    pub commitment: Vec<u8>,
94
    pub rangeproof: Option<Vec<u8>>,
95
    pub spending_key: String,
96
    pub value: i64,
97
    pub output_type: i32,
98
    pub maturity: i64,
99
    pub status: i32,
100
    pub hash: Vec<u8>,
101
    pub script: Vec<u8>,
102
    pub input_data: Vec<u8>,
103
    pub script_private_key: String,
104
    pub script_lock_height: i64,
105
    pub sender_offset_public_key: Vec<u8>,
106
    pub metadata_signature_ephemeral_commitment: Vec<u8>,
107
    pub metadata_signature_ephemeral_pubkey: Vec<u8>,
108
    pub metadata_signature_u_a: Vec<u8>,
109
    pub metadata_signature_u_x: Vec<u8>,
110
    pub metadata_signature_u_y: Vec<u8>,
111
    pub mined_height: Option<i64>,
112
    pub mined_in_block: Option<Vec<u8>>,
113
    pub marked_deleted_at_height: Option<i64>,
114
    pub marked_deleted_in_block: Option<Vec<u8>>,
115
    pub received_in_tx_id: Option<i64>,
116
    pub spent_in_tx_id: Option<i64>,
117
    pub coinbase_extra: Option<Vec<u8>>,
118
    pub features_json: String,
119
    pub spending_priority: i32,
120
    pub covenant: Vec<u8>,
121
    pub mined_timestamp: Option<NaiveDateTime>,
122
    pub encrypted_data: Vec<u8>,
123
    pub minimum_value_promise: i64,
124
    pub source: i32,
125
    pub last_validation_timestamp: Option<NaiveDateTime>,
126
    pub payment_id: Option<Vec<u8>>,
127
    pub user_payment_id: Option<Vec<u8>>,
128
}
129

130
impl OutputSql {
131
    /// Return all outputs
132
    pub fn index(conn: &mut SqliteConnection) -> Result<Vec<OutputSql>, OutputManagerStorageError> {
2✔
133
        Ok(outputs::table.load::<OutputSql>(conn)?)
2✔
134
    }
2✔
135

136
    /// Return all outputs with a given status
137
    pub fn index_status(
2✔
138
        statuses: Vec<OutputStatus>,
2✔
139
        conn: &mut SqliteConnection,
2✔
140
    ) -> Result<Vec<OutputSql>, OutputManagerStorageError> {
2✔
141
        Ok(outputs::table
2✔
142
            .filter(outputs::status.eq_any::<Vec<i32>>(statuses.into_iter().map(|s| s as i32).collect()))
2✔
143
            .load(conn)?)
2✔
144
    }
2✔
145

146
    /// Retrieves UTXOs by a set of given rules
147
    #[allow(clippy::cast_sign_loss)]
148
    pub fn fetch_outputs_by_query(
×
149
        q: OutputBackendQuery,
×
150
        conn: &mut SqliteConnection,
×
151
    ) -> Result<Vec<OutputSql>, OutputManagerStorageError> {
×
152
        let mut query = outputs::table
×
153
            .into_boxed()
×
154
            .filter(outputs::script_lock_height.le(q.tip_height))
×
155
            .filter(outputs::maturity.le(q.tip_height));
×
156

157
        if let Some((offset, limit)) = q.pagination {
×
158
            query = query.offset(offset).limit(limit);
×
159
        }
×
160

161
        // filtering by OutputStatus
162
        query = match q.status.len() {
×
163
            0 => query,
×
164
            1 => query.filter(outputs::status.eq(*q.status.first().expect("Already checked") as i32)),
×
165
            _ => query.filter(outputs::status.eq_any::<Vec<i32>>(q.status.into_iter().map(|s| s as i32).collect())),
×
166
        };
167

168
        // filtering by Commitment
169
        if !q.commitments.is_empty() {
×
170
            query = match q.commitments.len() {
×
171
                0 => query,
×
172
                1 => query.filter(outputs::commitment.eq(q.commitments.first().expect("Already checked").to_vec())),
×
173
                _ => query.filter(
×
174
                    outputs::commitment.eq_any::<Vec<Vec<u8>>>(q.commitments.into_iter().map(|c| c.to_vec()).collect()),
×
175
                ),
176
            };
177
        }
×
178

179
        // if set, filtering by minimum value
180
        if let Some((min, is_inclusive)) = q.value_min {
×
181
            query = if is_inclusive {
×
182
                query.filter(outputs::value.ge(min))
×
183
            } else {
184
                query.filter(outputs::value.gt(min))
×
185
            };
186
        }
×
187

188
        // if set, filtering by max value
189
        if let Some((max, is_inclusive)) = q.value_max {
×
190
            query = if is_inclusive {
×
191
                query.filter(outputs::value.le(max))
×
192
            } else {
193
                query.filter(outputs::value.lt(max))
×
194
            };
195
        }
×
196

197
        use SortDirection::{Asc, Desc};
198
        Ok(q.sorting
×
199
            .into_iter()
×
200
            .fold(query, |query, s| match s {
×
201
                ("value", d) => match d {
×
202
                    Asc => query.then_order_by(outputs::value.asc()),
×
203
                    Desc => query.then_order_by(outputs::value.desc()),
×
204
                },
205
                ("mined_height", d) => match d {
×
206
                    Asc => query.then_order_by(outputs::mined_height.asc()),
×
207
                    Desc => query.then_order_by(outputs::mined_height.desc()),
×
208
                },
209
                _ => query,
×
210
            })
×
211
            .load(conn)?)
×
212
    }
×
213

214
    /// Retrieves UTXOs than can be spent, sorted by priority, then value from smallest to largest.
215
    #[allow(clippy::cast_sign_loss, clippy::too_many_lines)]
216
    pub fn fetch_unspent_outputs_for_spending(
×
217
        selection_criteria: &UtxoSelectionCriteria,
×
218
        amount: u64,
×
219
        tip_height: Option<u64>,
×
220
        conn: &mut SqliteConnection,
×
221
    ) -> Result<Vec<OutputSql>, OutputManagerStorageError> {
×
222
        let i64_tip_height = tip_height.and_then(|h| i64::try_from(h).ok()).unwrap_or(i64::MAX);
×
223
        let i64_value = i64::try_from(selection_criteria.min_dust).unwrap_or(i64::MAX);
×
224

225
        let mut query = outputs::table
×
226
            .into_boxed()
×
227
            .filter(outputs::status.eq(OutputStatus::Unspent as i32))
×
228
            .filter(outputs::value.gt(i64_value))
×
229
            .order_by(outputs::spending_priority.desc());
×
230

231
        // NOTE: Safe mode presets `script_lock_height` and `maturity` filters for all queries
232
        if selection_criteria.mode == UtxoSelectionMode::Safe {
×
233
            query = query
×
234
                .filter(outputs::script_lock_height.le(i64_tip_height))
×
235
                .filter(outputs::maturity.le(i64_tip_height));
×
236
        };
×
237

238
        match &selection_criteria.filter {
×
239
            UtxoSelectionFilter::Standard => {
240
                query = query.filter(
×
241
                    outputs::output_type
×
242
                        .eq(i32::from(OutputType::Standard.as_byte()))
×
243
                        .or(outputs::output_type.eq(i32::from(OutputType::Coinbase.as_byte()))),
×
244
                );
245

246
                if selection_criteria.excluding_onesided {
×
247
                    query = query.filter(outputs::source.ne(OutputSource::OneSided as i32));
×
248
                }
×
249

250
                if selection_criteria.excluding_multisig {
×
251
                    query = query.filter(outputs::source.ne(OutputSource::Multisig as i32));
×
252
                }
×
253
            },
254

255
            UtxoSelectionFilter::SpecificOutputs { commitments } => {
×
256
                query = match commitments.len() {
×
257
                    0 => query,
×
258
                    1 => query.filter(outputs::commitment.eq(commitments.first().expect("Already checked").to_vec())),
×
259
                    _ => query.filter(
×
260
                        outputs::commitment.eq_any::<Vec<Vec<u8>>>(commitments.iter().map(|c| c.to_vec()).collect()),
×
261
                    ),
262
                };
263
            },
264

265
            UtxoSelectionFilter::MustInclude { commitments } => {
×
266
                return Self::handle_must_include_filter(selection_criteria, commitments, amount, tip_height, conn);
×
267
            },
268
        }
269

270
        for exclude in &selection_criteria.excluding {
×
271
            query = query.filter(outputs::commitment.ne(exclude.as_bytes()));
×
272
        }
×
273

274
        query = match selection_criteria.ordering {
×
275
            UtxoSelectionOrdering::SmallestFirst => query.then_order_by(outputs::value.asc()),
×
276
            UtxoSelectionOrdering::LargestFirst => query.then_order_by(outputs::value.desc()),
×
277
            UtxoSelectionOrdering::Default => {
278
                // NOTE: keeping filtering by `script_lock_height` and `maturity` for all modes
279
                // lets get the max value for all utxos
280
                let max: Option<i64> = outputs::table
×
281
                    .filter(outputs::status.eq(OutputStatus::Unspent as i32))
×
282
                    .filter(outputs::script_lock_height.le(i64_tip_height))
×
283
                    .filter(outputs::maturity.le(i64_tip_height))
×
284
                    .order(outputs::value.desc())
×
285
                    .select(outputs::value)
×
286
                    .first(conn)
×
287
                    .optional()?;
×
288

289
                match max {
×
290
                    // Want to reduce the number of inputs to reduce fees
291
                    Some(max) if amount > max as u64 => query.then_order_by(outputs::value.desc()),
×
292

293
                    // Use the smaller utxos to make up this transaction.
294
                    _ => query.then_order_by(outputs::value.asc()),
×
295
                }
296
            },
297
        };
298

299
        Ok(query.limit(i64::from(TRANSACTION_INPUTS_LIMIT)).load(conn)?)
×
300
    }
×
301

302
    /// Retrieves UTXOs within a specified limited range with minimum target amount for spending. If not enough UTXOs
303
    /// can be found, an empty vector is returned.
NEW
304
    pub fn get_range_limited_outputs_for_spending(
×
NEW
305
        selection_criteria: &UtxoSelectionCriteria,
×
NEW
306
        tip_height: Option<u64>,
×
NEW
307
        conn: &mut SqliteConnection,
×
NEW
308
    ) -> Result<(Vec<OutputSql>, MicroMinotari), OutputManagerStorageError> {
×
NEW
309
        let range_limit =
×
NEW
310
            selection_criteria
×
NEW
311
                .range_limit
×
NEW
312
                .as_ref()
×
NEW
313
                .ok_or_else(|| OutputManagerStorageError::RangeLimitError {
×
NEW
314
                    reason: "Range limit must be specified".to_string(),
×
NEW
315
                })?;
×
NEW
316
        let amounts_from = i64::try_from(range_limit.range.start).unwrap_or(i64::MAX);
×
NEW
317
        let amounts_to = i64::try_from(range_limit.range.end).unwrap_or(i64::MAX);
×
318

NEW
319
        let mut query = outputs::table
×
NEW
320
            .into_boxed()
×
NEW
321
            .filter(outputs::status.eq(OutputStatus::Unspent as i32))
×
NEW
322
            .filter(outputs::value.ge(amounts_from))
×
NEW
323
            .filter(outputs::value.lt(amounts_to));
×
324

325
        // NOTE: Safe mode presets `script_lock_height` and `maturity` filters for all queries
NEW
326
        let i64_tip_height = tip_height.and_then(|h| i64::try_from(h).ok()).unwrap_or(i64::MAX);
×
NEW
327
        if selection_criteria.mode == UtxoSelectionMode::Safe {
×
NEW
328
            query = query
×
NEW
329
                .filter(outputs::script_lock_height.le(i64_tip_height))
×
NEW
330
                .filter(outputs::maturity.le(i64_tip_height));
×
NEW
331
        };
×
332

NEW
333
        for exclude in &selection_criteria.excluding {
×
NEW
334
            query = query.filter(outputs::commitment.ne(exclude.as_bytes()));
×
NEW
335
        }
×
336

NEW
337
        query = query.then_order_by(outputs::value.asc());
×
338

NEW
339
        let transaction_input_limit = u32::try_from(range_limit.transaction_input_limit)
×
NEW
340
            .unwrap_or(u32::MAX)
×
NEW
341
            .min(TRANSACTION_INPUTS_LIMIT);
×
NEW
342
        let outputs: Vec<OutputSql> = query.limit(i64::from(transaction_input_limit)).load(conn)?;
×
343

344
        // If all the outputs together don't reach target, we cannot continue
NEW
345
        let total_sum: u64 = outputs.iter().fold(0u64, |acc, o| acc.saturating_add(o.value as u64));
×
NEW
346
        if total_sum < range_limit.target_minimum_amount {
×
NEW
347
            debug!(
×
NEW
348
                target: LOG_TARGET,
×
NEW
349
                "Total unspent outputs' value in the specified range was less than the target_minimum_amount: {} < {}",
×
350
                total_sum, range_limit.target_minimum_amount
351
            );
NEW
352
            return Ok((Vec::new(), MicroMinotari::zero()));
×
NEW
353
        }
×
354

NEW
355
        Ok((outputs, MicroMinotari::from(total_sum)))
×
NEW
356
    }
×
357

358
    /// Retrieves UTXO counts grouped by the provided ranges
NEW
359
    pub fn count_outputs_in_ranges(
×
NEW
360
        selection_criteria: &UtxoSelectionCriteria,
×
NEW
361
        ranges: &[Range<u64>],
×
NEW
362
        tip_height: Option<u64>,
×
NEW
363
        conn: &mut SqliteConnection,
×
NEW
364
    ) -> Result<Vec<CoinBucket>, OutputManagerStorageError> {
×
NEW
365
        let mut result = Vec::with_capacity(ranges.len());
×
NEW
366
        let i64_tip_height = tip_height.and_then(|h| i64::try_from(h).ok()).unwrap_or(i64::MAX);
×
367

NEW
368
        for range in ranges {
×
NEW
369
            let amounts_from = i64::try_from(range.start).unwrap_or(i64::MAX);
×
NEW
370
            let amounts_to = i64::try_from(range.end).unwrap_or(i64::MAX);
×
371

NEW
372
            let mut query = outputs::table
×
NEW
373
                .into_boxed()
×
NEW
374
                .filter(outputs::status.eq(OutputStatus::Unspent as i32))
×
NEW
375
                .filter(outputs::value.ge(amounts_from))
×
NEW
376
                .filter(outputs::value.lt(amounts_to));
×
377

NEW
378
            if selection_criteria.mode == UtxoSelectionMode::Safe {
×
NEW
379
                query = query
×
NEW
380
                    .filter(outputs::script_lock_height.le(i64_tip_height))
×
NEW
381
                    .filter(outputs::maturity.le(i64_tip_height));
×
NEW
382
            }
×
383

384
            // Rust
NEW
385
            let (count_res, sum_res) = query
×
NEW
386
                .select((count_star(), sql::<Nullable<BigInt>>("SUM(value)")))
×
NEW
387
                .first::<(i64, Option<i64>)>(conn)
×
NEW
388
                .optional()?
×
NEW
389
                .unwrap_or_default();
×
390

NEW
391
            result.push(CoinBucket {
×
NEW
392
                number_of_outputs: count_res as u64,
×
NEW
393
                total_value: sum_res.unwrap_or(0) as u64,
×
NEW
394
                range: range.clone(),
×
NEW
395
            });
×
396
        }
397

NEW
398
        Ok(result)
×
NEW
399
    }
×
400

401
    fn handle_must_include_filter(
×
402
        selection_criteria: &UtxoSelectionCriteria,
×
403
        commitments: &[CompressedCommitment],
×
404
        amount: u64,
×
405
        tip_height: Option<u64>,
×
406
        conn: &mut SqliteConnection,
×
407
    ) -> Result<Vec<OutputSql>, OutputManagerStorageError> {
×
408
        if commitments.is_empty() {
×
409
            // If no commitments specified, fall back to standard behavior
410
            let mut selection_criteria = selection_criteria.clone();
×
411
            selection_criteria.filter = UtxoSelectionFilter::Standard;
×
412
            return OutputSql::fetch_unspent_outputs_for_spending(&selection_criteria, amount, tip_height, conn);
×
413
        }
×
414

415
        let i64_tip_height = tip_height.and_then(|h| i64::try_from(h).ok()).unwrap_or(i64::MAX);
×
416
        let i64_value = i64::try_from(selection_criteria.min_dust).unwrap_or(i64::MAX);
×
417

418
        let mut query = outputs::table
×
419
            .into_boxed()
×
420
            .filter(outputs::status.eq(OutputStatus::Unspent as i32))
×
421
            .filter(outputs::value.gt(i64_value))
×
422
            .order_by(outputs::spending_priority.desc());
×
423

424
        // NOTE: Safe mode presets `script_lock_height` and `maturity` filters for all queries
425
        if selection_criteria.mode == UtxoSelectionMode::Safe {
×
426
            query = query
×
427
                .filter(outputs::script_lock_height.le(i64_tip_height))
×
428
                .filter(outputs::maturity.le(i64_tip_height));
×
429
        }
×
430

431
        query = query.filter(
×
432
            outputs::output_type
×
433
                .eq(i32::from(OutputType::Standard.as_byte()))
×
434
                .or(outputs::output_type.eq(i32::from(OutputType::Coinbase.as_byte()))),
×
435
        );
436

437
        if selection_criteria.excluding_onesided {
×
438
            query = query.filter(outputs::source.ne(OutputSource::OneSided as i32));
×
439
        }
×
440

441
        if selection_criteria.excluding_multisig {
×
442
            query = query.filter(outputs::source.ne(OutputSource::Multisig as i32));
×
443
        }
×
444

445
        // Exclude the must-include outputs from the main query
446
        for commitment in commitments {
×
447
            query = query.filter(outputs::commitment.ne(commitment.to_vec()));
×
448
        }
×
449

450
        for exclude in &selection_criteria.excluding {
×
451
            query = query.filter(outputs::commitment.ne(exclude.as_bytes()));
×
452
        }
×
453

454
        query = match selection_criteria.ordering {
×
455
            UtxoSelectionOrdering::SmallestFirst => query.then_order_by(outputs::value.asc()),
×
456
            UtxoSelectionOrdering::LargestFirst => query.then_order_by(outputs::value.desc()),
×
457
            UtxoSelectionOrdering::Default => {
458
                let max: Option<i64> = outputs::table
×
459
                    .filter(outputs::status.eq(OutputStatus::Unspent as i32))
×
460
                    .filter(outputs::script_lock_height.le(i64_tip_height))
×
461
                    .filter(outputs::maturity.le(i64_tip_height))
×
462
                    .order(outputs::value.desc())
×
463
                    .select(outputs::value)
×
464
                    .first(conn)
×
465
                    .optional()?;
×
466

467
                match max {
×
468
                    Some(max) if amount > max as u64 => query.then_order_by(outputs::value.desc()),
×
469
                    _ => query.then_order_by(outputs::value.asc()),
×
470
                }
471
            },
472
        };
473

474
        // First, get the must-include outputs
475
        let mut must_include_query = outputs::table
×
476
            .into_boxed()
×
477
            .filter(outputs::status.eq(OutputStatus::Unspent as i32))
×
478
            .filter(outputs::value.gt(i64_value))
×
479
            .order_by(outputs::spending_priority.desc());
×
480

481
        // Apply safe mode filters if needed
482
        if selection_criteria.mode == UtxoSelectionMode::Safe {
×
483
            must_include_query = must_include_query
×
484
                .filter(outputs::script_lock_height.le(i64_tip_height))
×
485
                .filter(outputs::maturity.le(i64_tip_height));
×
486
        }
×
487

488
        // Filter for the specific commitments
489
        must_include_query = must_include_query
×
490
            .filter(outputs::commitment.eq_any::<Vec<Vec<u8>>>(commitments.iter().map(|c| c.to_vec()).collect()));
×
491

492
        // Apply excluding filters
493
        for exclude in &selection_criteria.excluding {
×
494
            must_include_query = must_include_query.filter(outputs::commitment.ne(exclude.as_bytes()));
×
495
        }
×
496

497
        let must_include_outputs: Vec<OutputSql> = must_include_query.load(conn)?;
×
498

499
        // Calculate total value of must-include outputs
500
        let must_include_total: i64 = must_include_outputs.iter().map(|o| o.value).sum();
×
501
        let i64_amount = i64::try_from(amount).unwrap_or(i64::MAX);
×
502

503
        // If must-include outputs are sufficient, return only them
504
        if must_include_total >= i64_amount {
×
505
            return Ok(must_include_outputs);
×
506
        }
×
507

508
        // Otherwise, we need additional outputs
509
        let remaining_limit = i64::from(TRANSACTION_INPUTS_LIMIT) - must_include_outputs.len() as i64;
×
510
        let mut final_outputs = must_include_outputs;
×
511

512
        if remaining_limit > 0 {
×
513
            let additional_outputs: Vec<OutputSql> = query.limit(remaining_limit).load(conn)?;
×
514
            final_outputs.extend(additional_outputs);
×
515
        }
×
516

517
        Ok(final_outputs)
×
518
    }
×
519

520
    /// Return all unspent outputs that have a maturity above the provided chain tip
521
    #[allow(clippy::cast_possible_wrap)]
522
    pub fn index_time_locked(
×
523
        tip: u64,
×
524
        conn: &mut SqliteConnection,
×
525
    ) -> Result<Vec<OutputSql>, OutputManagerStorageError> {
×
526
        Ok(outputs::table
×
527
            .filter(outputs::status.eq(OutputStatus::Unspent as i32))
×
528
            .filter(outputs::maturity.gt(tip as i64))
×
529
            .load(conn)?)
×
530
    }
×
531

532
    pub fn index_unconfirmed(conn: &mut SqliteConnection) -> Result<Vec<OutputSql>, OutputManagerStorageError> {
×
533
        Ok(outputs::table
×
534
            .filter(
×
535
                outputs::status
×
536
                    .eq(OutputStatus::UnspentMinedUnconfirmed as i32)
×
537
                    .or(outputs::mined_in_block.is_null())
×
538
                    .or(outputs::mined_height.is_null()),
×
539
            )
540
            .order(outputs::id.asc())
×
541
            .load(conn)?)
×
542
    }
×
543

544
    pub fn index_by_output_type(
×
545
        output_type: OutputType,
×
546
        conn: &mut SqliteConnection,
×
547
    ) -> Result<Vec<OutputSql>, OutputManagerStorageError> {
×
548
        let res = diesel::sql_query("SELECT * FROM outputs where output_type & $1 = $1 ORDER BY id;")
×
549
            .bind::<diesel::sql_types::Integer, _>(i32::from(output_type.as_byte()))
×
550
            .load(conn)?;
×
551
        Ok(res)
×
552
    }
×
553

554
    pub fn index_unspent(conn: &mut SqliteConnection) -> Result<Vec<OutputSql>, OutputManagerStorageError> {
×
555
        Ok(outputs::table
×
556
            .filter(outputs::status.eq(OutputStatus::Unspent as i32))
×
557
            .order(outputs::id.asc())
×
558
            .load(conn)?)
×
559
    }
×
560

561
    pub fn index_marked_deleted_in_block_is_null(
×
562
        conn: &mut SqliteConnection,
×
563
    ) -> Result<Vec<OutputSql>, OutputManagerStorageError> {
×
564
        Ok(outputs::table
×
565
            // Return outputs not marked as deleted or confirmed
566
            .filter(outputs::marked_deleted_in_block.is_null().or(outputs::status.eq(OutputStatus::SpentMinedUnconfirmed as i32)))
×
567
            // Only return mined
568
            .filter(outputs::mined_in_block.is_not_null().and(outputs::mined_height.is_not_null()))
×
569
            .order(outputs::id.asc())
×
570
            .load(conn)?)
×
571
    }
×
572

573
    pub fn index_invalid(
×
574
        timestamp: &NaiveDateTime,
×
575
        conn: &mut SqliteConnection,
×
576
    ) -> Result<Vec<OutputSql>, OutputManagerStorageError> {
×
577
        Ok(outputs::table
×
578
            .filter(
×
579
                outputs::status
×
580
                    .eq(OutputStatus::Invalid as i32)
×
581
                    .or(outputs::status.eq(OutputStatus::CancelledInbound as i32)),
×
582
            )
583
            .filter(
×
584
                outputs::last_validation_timestamp
×
585
                    .le(timestamp)
×
586
                    .or(outputs::last_validation_timestamp.is_null()),
×
587
            )
588
            .order(outputs::id.asc())
×
589
            .load(conn)?)
×
590
    }
×
591

592
    pub fn index_by_output_hashes(
×
593
        conn: &mut SqliteConnection,
×
594
        hashes: &[HashOutput],
×
595
    ) -> Result<Vec<OutputSql>, OutputManagerStorageError> {
×
596
        let outputs = outputs::table
×
597
            .filter(outputs::hash.eq_any(hashes.iter().map(|h| h.as_slice())))
×
598
            .load(conn)?;
×
599

600
        Ok(outputs)
×
601
    }
×
602

603
    pub fn first_by_mined_height_desc(
×
604
        conn: &mut SqliteConnection,
×
605
    ) -> Result<Option<OutputSql>, OutputManagerStorageError> {
×
606
        Ok(outputs::table
×
607
            .filter(outputs::mined_height.is_not_null())
×
608
            .order(outputs::mined_height.desc())
×
609
            .first(conn)
×
610
            .optional()?)
×
611
    }
×
612

613
    pub fn first_by_marked_deleted_height_desc(
×
614
        conn: &mut SqliteConnection,
×
615
    ) -> Result<Option<OutputSql>, OutputManagerStorageError> {
×
616
        Ok(outputs::table
×
617
            .filter(outputs::marked_deleted_at_height.is_not_null())
×
618
            .order(outputs::marked_deleted_at_height.desc())
×
619
            .first(conn)
×
620
            .optional()?)
×
621
    }
×
622

623
    /// Find a particular Output, if it exists
624
    pub fn find(spending_key: &str, conn: &mut SqliteConnection) -> Result<OutputSql, OutputManagerStorageError> {
6✔
625
        Ok(outputs::table
6✔
626
            .filter(outputs::spending_key.eq(spending_key.to_string()))
6✔
627
            .first::<OutputSql>(conn)?)
6✔
628
    }
6✔
629

630
    pub fn find_by_tx_id(
×
631
        tx_id: TxId,
×
632
        conn: &mut SqliteConnection,
×
633
    ) -> Result<Vec<OutputSql>, OutputManagerStorageError> {
×
634
        Ok(outputs::table
×
635
            .filter(
×
636
                outputs::received_in_tx_id
×
637
                    .eq(tx_id.as_i64_wrapped())
×
638
                    .or(outputs::spent_in_tx_id.eq(tx_id.as_i64_wrapped())),
×
639
            )
640
            .load(conn)?)
×
641
    }
×
642

643
    /// Verify that outputs with specified commitments exist in the database
644
    pub fn verify_outputs_exist(
×
645
        commitments: &[CompressedCommitment],
×
646
        conn: &mut SqliteConnection,
×
647
    ) -> Result<bool, OutputManagerStorageError> {
×
648
        #[derive(QueryableByName, Clone)]
×
649
        struct CountQueryResult {
650
            #[diesel(sql_type = diesel::sql_types::BigInt)]
651
            count: i64,
652
        }
653
        let placeholders = commitments
×
654
            .iter()
×
655
            .map(|v| format!("x'{}'", v.to_hex()))
×
656
            .collect::<Vec<_>>()
×
657
            .join(", ");
×
658
        let query = sql_query(format!(
×
659
            "SELECT COUNT(*) as count FROM outputs WHERE commitment IN ({placeholders})"
×
660
        ));
661
        let query_result = query.load::<CountQueryResult>(conn)?;
×
662
        let commitments_len = i64::try_from(commitments.len())
×
663
            .map_err(|e| OutputManagerStorageError::ConversionError { reason: e.to_string() })?;
×
664
        Ok(query_result.first().expect("Already checked").count == commitments_len)
×
665
    }
×
666

667
    /// Return the available, time locked, pending incoming and pending outgoing balance
668
    #[allow(clippy::cast_possible_wrap)]
669
    pub fn get_balance(
×
670
        current_tip_for_time_lock_calculation: Option<u64>,
×
671
        conn: &mut SqliteConnection,
×
672
    ) -> Result<Balance, OutputManagerStorageError> {
×
673
        #[derive(QueryableByName, Clone)]
×
674
        struct BalanceQueryResult {
675
            #[diesel(sql_type = diesel::sql_types::BigInt)]
676
            amount: i64,
677
            #[diesel(sql_type = diesel::sql_types::Text)]
678
            category: String,
679
        }
680
        let balance_query_result = if let Some(current_tip) = current_tip_for_time_lock_calculation {
×
681
            sql_query(
×
682
                "SELECT coalesce(sum(value), 0) as amount, 'available_balance' as category \
683
                 FROM outputs WHERE status = ? AND maturity <= ? AND script_lock_height <= ? AND output_type != ? \
684
                 UNION ALL \
685
                 SELECT coalesce(sum(value), 0) as amount, 'time_locked_balance' as category \
686
                 FROM outputs WHERE status = ? AND (maturity > ? OR script_lock_height > ?) AND output_type != ? \
687
                 UNION ALL \
688
                 SELECT coalesce(sum(value), 0) as amount, 'pending_incoming_balance' as category \
689
                 FROM outputs WHERE (source != ? AND status = ? OR status = ? OR status = ?) AND output_type != ? \
690
                 UNION ALL \
691
                 SELECT coalesce(sum(value), 0) as amount, 'pending_outgoing_balance' as category \
692
                 FROM outputs WHERE status = ? OR status = ? OR status = ?",
693
            )
694
                // available_balance
695
                .bind::<diesel::sql_types::Integer, _>(OutputStatus::Unspent as i32)
×
696
                .bind::<diesel::sql_types::BigInt, _>(current_tip as i64)
×
697
                .bind::<diesel::sql_types::BigInt, _>(current_tip as i64)
×
698
                .bind::<diesel::sql_types::Integer, _>(OutputType::Burn as i32)
×
699
                // time_locked_balance
700
                .bind::<diesel::sql_types::Integer, _>(OutputStatus::Unspent as i32)
×
701
                .bind::<diesel::sql_types::BigInt, _>(current_tip as i64)
×
702
                .bind::<diesel::sql_types::BigInt, _>(current_tip as i64)
×
703
                .bind::<diesel::sql_types::Integer, _>(OutputType::Burn as i32)
×
704
                // pending_incoming_balance
705
                .bind::<diesel::sql_types::Integer, _>(OutputSource::Coinbase as i32)
×
706
                .bind::<diesel::sql_types::Integer, _>(OutputStatus::EncumberedToBeReceived as i32)
×
707
                .bind::<diesel::sql_types::Integer, _>(OutputStatus::ShortTermEncumberedToBeReceived as i32)
×
708
                .bind::<diesel::sql_types::Integer, _>(OutputStatus::UnspentMinedUnconfirmed as i32)
×
709
                .bind::<diesel::sql_types::Integer, _>(OutputType::Burn as i32)
×
710
                // pending_outgoing_balance
711
                .bind::<diesel::sql_types::Integer, _>(OutputStatus::EncumberedToBeSpent as i32)
×
712
                .bind::<diesel::sql_types::Integer, _>(OutputStatus::ShortTermEncumberedToBeSpent as i32)
×
713
                .bind::<diesel::sql_types::Integer, _>(OutputStatus::SpentMinedUnconfirmed as i32)
×
714
                .load::<BalanceQueryResult>(conn)?
×
715
        } else {
716
            sql_query(
×
717
                "SELECT coalesce(sum(value), 0) as amount, 'available_balance' as category \
718
                 FROM outputs WHERE status = ? AND output_type != ?\
719
                 UNION ALL \
720
                 SELECT coalesce(sum(value), 0) as amount, 'pending_incoming_balance' as category \
721
                 FROM outputs WHERE (source != ? AND status = ? OR status = ? OR status = ?) AND output_type != ? \
722
                 UNION ALL \
723
                 SELECT coalesce(sum(value), 0) as amount, 'pending_outgoing_balance' as category \
724
                 FROM outputs WHERE status = ? OR status = ? OR status = ?",
725
            )
726
                // available_balance
727
                .bind::<diesel::sql_types::Integer, _>(OutputStatus::Unspent as i32)
×
728
                .bind::<diesel::sql_types::Integer, _>(OutputType::Burn as i32)
×
729
                // pending_incoming_balance
730
                .bind::<diesel::sql_types::Integer, _>(OutputSource::Coinbase as i32)
×
731
                .bind::<diesel::sql_types::Integer, _>(OutputStatus::EncumberedToBeReceived as i32)
×
732
                .bind::<diesel::sql_types::Integer, _>(OutputStatus::ShortTermEncumberedToBeReceived as i32)
×
733
                .bind::<diesel::sql_types::Integer, _>(OutputStatus::UnspentMinedUnconfirmed as i32)
×
734
                .bind::<diesel::sql_types::Integer, _>(OutputType::Burn as i32)
×
735
                // pending_outgoing_balance
736
                .bind::<diesel::sql_types::Integer, _>(OutputStatus::EncumberedToBeSpent as i32)
×
737
                .bind::<diesel::sql_types::Integer, _>(OutputStatus::ShortTermEncumberedToBeSpent as i32)
×
738
                .bind::<diesel::sql_types::Integer, _>(OutputStatus::SpentMinedUnconfirmed as i32)
×
739
                .load::<BalanceQueryResult>(conn)?
×
740
        };
741
        let mut available_balance = None;
×
742
        let mut time_locked_balance = Some(None);
×
743
        let mut pending_incoming_balance = None;
×
744
        let mut pending_outgoing_balance = None;
×
745
        for balance in balance_query_result {
×
746
            match balance.category.as_str() {
×
747
                "available_balance" => available_balance = Some(MicroMinotari::from(balance.amount as u64)),
×
748
                "time_locked_balance" => time_locked_balance = Some(Some(MicroMinotari::from(balance.amount as u64))),
×
749
                "pending_incoming_balance" => {
×
750
                    pending_incoming_balance = Some(MicroMinotari::from(balance.amount as u64))
×
751
                },
752
                "pending_outgoing_balance" => {
×
753
                    pending_outgoing_balance = Some(MicroMinotari::from(balance.amount as u64))
×
754
                },
755
                _ => {
756
                    return Err(OutputManagerStorageError::UnexpectedResult(
×
757
                        "Unexpected category in balance query".to_string(),
×
758
                    ))
×
759
                },
760
            }
761
        }
762

763
        Ok(Balance {
764
            available_balance: available_balance.ok_or_else(|| {
×
765
                OutputManagerStorageError::UnexpectedResult("Available balance could not be calculated".to_string())
×
766
            })?,
×
767
            time_locked_balance: time_locked_balance.ok_or_else(|| {
×
768
                OutputManagerStorageError::UnexpectedResult("Time locked balance could not be calculated".to_string())
×
769
            })?,
×
770
            pending_incoming_balance: pending_incoming_balance.ok_or_else(|| {
×
771
                OutputManagerStorageError::UnexpectedResult(
×
772
                    "Pending incoming balance could not be calculated".to_string(),
×
773
                )
×
774
            })?,
×
775
            pending_outgoing_balance: pending_outgoing_balance.ok_or_else(|| {
×
776
                OutputManagerStorageError::UnexpectedResult(
×
777
                    "Pending outgoing balance could not be calculated".to_string(),
×
778
                )
×
779
            })?,
×
780
        })
781
    }
×
782

783
    /// Return the available, time locked, pending incoming and pending outgoing balance
784
    #[allow(clippy::cast_possible_wrap)]
785
    #[allow(clippy::too_many_lines)]
786
    pub fn get_balance_payment_id(
×
787
        current_tip_for_time_lock_calculation: Option<u64>,
×
788
        payment_id: Vec<u8>,
×
789
        conn: &mut SqliteConnection,
×
790
    ) -> Result<Balance, OutputManagerStorageError> {
×
791
        #[derive(QueryableByName, Clone)]
×
792
        struct BalanceQueryResult {
793
            #[diesel(sql_type = diesel::sql_types::BigInt)]
794
            amount: i64,
795
            #[diesel(sql_type = diesel::sql_types::Text)]
796
            category: String,
797
        }
798
        let balance_query_result = if let Some(current_tip) = current_tip_for_time_lock_calculation {
×
799
            let balance_query = sql_query(
×
800
                "SELECT coalesce(sum(value), 0) as amount, 'available_balance' as category \
801
                 FROM outputs WHERE status = ? AND maturity <= ? AND script_lock_height <= ? AND user_payment_id = ? \
802
                 UNION ALL \
803
                 SELECT coalesce(sum(value), 0) as amount, 'time_locked_balance' as category \
804
                 FROM outputs WHERE status = ? AND ((maturity > ? OR script_lock_height > ?) AND user_payment_id = ?) \
805
                 UNION ALL \
806
                 SELECT coalesce(sum(value), 0) as amount, 'pending_incoming_balance' as category \
807
                 FROM outputs WHERE source != ? AND (status = ? OR status = ? OR status = ?) AND user_payment_id = ? \
808
                 UNION ALL \
809
                 SELECT coalesce(sum(value), 0) as amount, 'pending_outgoing_balance' as category \
810
                 FROM outputs WHERE (status = ? OR status = ? OR status = ?) AND user_payment_id = ?",
811
            )
812
                // available_balance
813
                .bind::<diesel::sql_types::Integer, _>(OutputStatus::Unspent as i32)
×
814
                .bind::<diesel::sql_types::BigInt, _>(current_tip as i64)
×
815
                .bind::<diesel::sql_types::BigInt, _>(current_tip as i64)
×
816
                .bind::<diesel::sql_types::Binary, _>(payment_id.clone())
×
817
                // time_locked_balance
818
                .bind::<diesel::sql_types::Integer, _>(OutputStatus::Unspent as i32)
×
819
                .bind::<diesel::sql_types::BigInt, _>(current_tip as i64)
×
820
                .bind::<diesel::sql_types::BigInt, _>(current_tip as i64)
×
821
                .bind::<diesel::sql_types::Binary, _>(payment_id.clone())
×
822
                // pending_incoming_balance
823
                .bind::<diesel::sql_types::Integer, _>(OutputSource::Coinbase as i32)
×
824
                .bind::<diesel::sql_types::Integer, _>(OutputStatus::EncumberedToBeReceived as i32)
×
825
                .bind::<diesel::sql_types::Integer, _>(OutputStatus::ShortTermEncumberedToBeReceived as i32)
×
826
                .bind::<diesel::sql_types::Integer, _>(OutputStatus::UnspentMinedUnconfirmed as i32)
×
827
                .bind::<diesel::sql_types::Binary, _>(payment_id.clone())
×
828
                // pending_outgoing_balance
829
                .bind::<diesel::sql_types::Integer, _>(OutputStatus::EncumberedToBeSpent as i32)
×
830
                .bind::<diesel::sql_types::Integer, _>(OutputStatus::ShortTermEncumberedToBeSpent as i32)
×
831
                .bind::<diesel::sql_types::Integer, _>(OutputStatus::SpentMinedUnconfirmed as i32)
×
832
                .bind::<diesel::sql_types::Binary, _>(payment_id);
×
833
            balance_query.load::<BalanceQueryResult>(conn)?
×
834
        } else {
835
            let balance_query = sql_query(
×
836
             "SELECT coalesce(sum(value), 0) as amount, 'available_balance' as category \
837
             FROM outputs WHERE status = ? AND user_payment_id = ?\
838
             UNION ALL \
839
             SELECT coalesce(sum(value), 0) as amount, 'pending_incoming_balance' as category \
840
             FROM outputs WHERE source != ? AND (status = ? OR status = ? OR status = ?) AND user_payment_id = ? \
841
             UNION ALL \
842
             SELECT coalesce(sum(value), 0) as amount, 'pending_outgoing_balance' as category \
843
             FROM outputs WHERE (status = ? OR status = ? OR status = ?) AND user_payment_id = ?",
844
            )
845
                // available_balance
846
                .bind::<diesel::sql_types::Integer, _>(OutputStatus::Unspent as i32)
×
847
                .bind::<diesel::sql_types::Binary, _>(payment_id.clone())
×
848
                // pending_incoming_balance
849
                .bind::<diesel::sql_types::Integer, _>(OutputSource::Coinbase as i32)
×
850
                .bind::<diesel::sql_types::Integer, _>(OutputStatus::EncumberedToBeReceived as i32)
×
851
                .bind::<diesel::sql_types::Integer, _>(OutputStatus::ShortTermEncumberedToBeReceived as i32)
×
852
                .bind::<diesel::sql_types::Integer, _>(OutputStatus::UnspentMinedUnconfirmed as i32)
×
853
                .bind::<diesel::sql_types::Binary, _>(payment_id.clone())
×
854
                // pending_outgoing_balance
855
                .bind::<diesel::sql_types::Integer, _>(OutputStatus::EncumberedToBeSpent as i32)
×
856
                .bind::<diesel::sql_types::Integer, _>(OutputStatus::ShortTermEncumberedToBeSpent as i32)
×
857
                .bind::<diesel::sql_types::Integer, _>(OutputStatus::SpentMinedUnconfirmed as i32)
×
858
                .bind::<diesel::sql_types::Binary, _>(payment_id);
×
859
            balance_query.load::<BalanceQueryResult>(conn)?
×
860
        };
861
        let mut available_balance = None;
×
862
        let mut time_locked_balance = Some(None);
×
863
        let mut pending_incoming_balance = None;
×
864
        let mut pending_outgoing_balance = None;
×
865
        for balance in balance_query_result {
×
866
            match balance.category.as_str() {
×
867
                "available_balance" => available_balance = Some(MicroMinotari::from(balance.amount as u64)),
×
868
                "time_locked_balance" => time_locked_balance = Some(Some(MicroMinotari::from(balance.amount as u64))),
×
869
                "pending_incoming_balance" => {
×
870
                    pending_incoming_balance = Some(MicroMinotari::from(balance.amount as u64))
×
871
                },
872
                "pending_outgoing_balance" => {
×
873
                    pending_outgoing_balance = Some(MicroMinotari::from(balance.amount as u64))
×
874
                },
875
                _ => {
876
                    return Err(OutputManagerStorageError::UnexpectedResult(
×
877
                        "Unexpected category in balance query".to_string(),
×
878
                    ))
×
879
                },
880
            }
881
        }
882

883
        Ok(Balance {
884
            available_balance: available_balance.ok_or_else(|| {
×
885
                OutputManagerStorageError::UnexpectedResult("Available balance could not be calculated".to_string())
×
886
            })?,
×
887
            time_locked_balance: time_locked_balance.ok_or_else(|| {
×
888
                OutputManagerStorageError::UnexpectedResult("Time locked balance could not be calculated".to_string())
×
889
            })?,
×
890
            pending_incoming_balance: pending_incoming_balance.ok_or_else(|| {
×
891
                OutputManagerStorageError::UnexpectedResult(
×
892
                    "Pending incoming balance could not be calculated".to_string(),
×
893
                )
×
894
            })?,
×
895
            pending_outgoing_balance: pending_outgoing_balance.ok_or_else(|| {
×
896
                OutputManagerStorageError::UnexpectedResult(
×
897
                    "Pending outgoing balance could not be calculated".to_string(),
×
898
                )
×
899
            })?,
×
900
        })
901
    }
×
902

903
    pub fn find_by_commitment(
×
904
        commitment: &[u8],
×
905
        conn: &mut SqliteConnection,
×
906
    ) -> Result<OutputSql, OutputManagerStorageError> {
×
907
        Ok(outputs::table
×
908
            .filter(outputs::commitment.eq(commitment))
×
909
            .first::<OutputSql>(conn)?)
×
910
    }
×
911

912
    pub fn find_by_commitments_excluding_status(
×
913
        commitments: Vec<&[u8]>,
×
914
        status: OutputStatus,
×
915
        conn: &mut SqliteConnection,
×
916
    ) -> Result<Vec<OutputSql>, OutputManagerStorageError> {
×
917
        Ok(outputs::table
×
918
            .filter(outputs::commitment.eq_any(commitments))
×
919
            .filter(outputs::status.ne(status as i32))
×
920
            .load(conn)?)
×
921
    }
×
922

923
    pub fn update_by_commitments(
×
924
        commitments: Vec<&[u8]>,
×
925
        updated_output: UpdateOutput,
×
926
        conn: &mut SqliteConnection,
×
927
    ) -> Result<usize, OutputManagerStorageError> {
×
928
        Ok(
929
            diesel::update(outputs::table.filter(outputs::commitment.eq_any(commitments)))
×
930
                .set(UpdateOutputSql::from(updated_output))
×
931
                .execute(conn)?,
×
932
        )
933
    }
×
934

935
    pub fn find_by_commitment_and_cancelled(
×
936
        commitment: &[u8],
×
937
        cancelled: bool,
×
938
        conn: &mut SqliteConnection,
×
939
    ) -> Result<OutputSql, OutputManagerStorageError> {
×
940
        let cancelled_flag = OutputStatus::CancelledInbound as i32;
×
941

942
        let mut request = outputs::table.filter(outputs::commitment.eq(commitment)).into_boxed();
×
943
        if cancelled {
×
944
            request = request.filter(outputs::status.eq(cancelled_flag))
×
945
        } else {
946
            request = request.filter(outputs::status.ne(cancelled_flag))
×
947
        };
948

949
        Ok(request.first::<OutputSql>(conn)?)
×
950
    }
×
951

952
    pub fn find_by_tx_id_and_status(
×
953
        tx_id: TxId,
×
954
        status: OutputStatus,
×
955
        conn: &mut SqliteConnection,
×
956
    ) -> Result<Vec<OutputSql>, OutputManagerStorageError> {
×
957
        Ok(outputs::table
×
958
            .filter(
×
959
                outputs::received_in_tx_id
×
960
                    .eq(Some(tx_id.as_u64() as i64))
×
961
                    .or(outputs::spent_in_tx_id.eq(Some(tx_id.as_u64() as i64))),
×
962
            )
963
            .filter(outputs::status.eq(status as i32))
×
964
            .load(conn)?)
×
965
    }
×
966

967
    /// Find outputs via tx_id that are encumbered. Any outputs that are encumbered cannot be marked as spent.
968
    pub fn find_by_tx_id_and_encumbered(
1✔
969
        tx_id: TxId,
1✔
970
        conn: &mut SqliteConnection,
1✔
971
    ) -> Result<Vec<OutputSql>, OutputManagerStorageError> {
1✔
972
        Ok(outputs::table
1✔
973
            .filter(
1✔
974
                outputs::received_in_tx_id
1✔
975
                    .eq(Some(tx_id.as_u64() as i64))
1✔
976
                    .or(outputs::spent_in_tx_id.eq(Some(tx_id.as_u64() as i64))),
1✔
977
            )
978
            .filter(
1✔
979
                outputs::status
1✔
980
                    .eq(OutputStatus::EncumberedToBeReceived as i32)
1✔
981
                    .or(outputs::status.eq(OutputStatus::EncumberedToBeSpent as i32))
1✔
982
                    .or(outputs::status.eq(OutputStatus::ShortTermEncumberedToBeReceived as i32))
1✔
983
                    .or(outputs::status.eq(OutputStatus::ShortTermEncumberedToBeSpent as i32)),
1✔
984
            )
985
            .load(conn)?)
1✔
986
    }
1✔
987

988
    /// Find a particular Output, if it exists and is in the specified Spent state
989
    pub fn find_status(
2✔
990
        spending_key: &str,
2✔
991
        status: OutputStatus,
2✔
992
        conn: &mut SqliteConnection,
2✔
993
    ) -> Result<OutputSql, OutputManagerStorageError> {
2✔
994
        Ok(outputs::table
2✔
995
            .filter(outputs::status.eq(status as i32))
2✔
996
            .filter(outputs::spending_key.eq(spending_key.to_string()))
2✔
997
            .first::<OutputSql>(conn)?)
2✔
998
    }
2✔
999

1000
    /// Find a particular Output, if it exists and is in the specified Spent state
1001
    pub fn find_by_hash(
×
1002
        hash: &[u8],
×
1003
        status: OutputStatus,
×
1004
        conn: &mut SqliteConnection,
×
1005
    ) -> Result<OutputSql, OutputManagerStorageError> {
×
1006
        Ok(outputs::table
×
1007
            .filter(outputs::status.eq(status as i32))
×
1008
            .filter(outputs::hash.eq(hash))
×
1009
            .first::<OutputSql>(conn)?)
×
1010
    }
×
1011

1012
    pub fn delete(&self, conn: &mut SqliteConnection) -> Result<(), OutputManagerStorageError> {
1✔
1013
        let num_deleted =
1✔
1014
            diesel::delete(outputs::table.filter(outputs::spending_key.eq(&self.spending_key))).execute(conn)?;
1✔
1015

1016
        if num_deleted == 0 {
1✔
1017
            return Err(OutputManagerStorageError::ValuesNotFound);
×
1018
        }
1✔
1019

1020
        Ok(())
1✔
1021
    }
1✔
1022

1023
    pub fn update(
2✔
1024
        &self,
2✔
1025
        updated_output: UpdateOutput,
2✔
1026
        conn: &mut SqliteConnection,
2✔
1027
    ) -> Result<OutputSql, OutputManagerStorageError> {
2✔
1028
        diesel::update(outputs::table.filter(outputs::id.eq(&self.id)))
2✔
1029
            .set(UpdateOutputSql::from(updated_output))
2✔
1030
            .execute(conn)
2✔
1031
            .num_rows_affected_or_not_found(1)?;
2✔
1032

1033
        OutputSql::find(&self.spending_key, conn)
2✔
1034
    }
2✔
1035

1036
    #[allow(clippy::too_many_lines)]
1037
    pub fn to_db_wallet_output(self) -> Result<DbWalletOutput, OutputManagerStorageError> {
×
1038
        let features: OutputFeatures =
×
1039
            serde_json::from_str(&self.features_json).map_err(|s| OutputManagerStorageError::ConversionError {
×
1040
                reason: format!("Could not convert json into OutputFeatures:{s}"),
×
1041
            })?;
×
1042

1043
        let covenant = BorshDeserialize::deserialize(&mut self.covenant.as_bytes()).map_err(|e| {
×
1044
            error!(
×
1045
                target: LOG_TARGET,
×
1046
                "Could not create Covenant from stored bytes ({e}), They might be encrypted"
×
1047
            );
1048
            OutputManagerStorageError::ConversionError {
×
1049
                reason: "Covenant could not be converted from bytes".to_string(),
×
1050
            }
×
1051
        })?;
×
1052

1053
        let encrypted_data = EncryptedData::from_bytes(&self.encrypted_data)?;
×
1054
        let payment_id = match self.payment_id {
×
1055
            Some(bytes) => MemoField::from_bytes(&bytes),
×
1056
            None => MemoField::new_empty(),
×
1057
        };
1058
        let commitment = CompressedCommitment::from_vec(&self.commitment)?;
×
1059
        let hash = match <Vec<u8> as TryInto<FixedHash>>::try_into(self.hash) {
×
1060
            Ok(v) => v,
×
1061
            Err(e) => {
×
1062
                error!(target: LOG_TARGET, "Malformed output hash: {e}");
×
1063
                return Err(OutputManagerStorageError::ConversionError {
×
1064
                    reason: "Malformed output hash".to_string(),
×
1065
                });
×
1066
            },
1067
        };
1068
        let wallet_output = WalletOutput::new_from_parts(
×
1069
            TransactionOutputVersion::get_current_version(),
×
1070
            MicroMinotari::from(self.value as u64),
×
1071
            TariKeyId::from_str(&self.spending_key).map_err(|e| {
×
1072
                error!(
×
1073
                    target: LOG_TARGET,
×
1074
                    "Could not create spending key id from stored string ({e})"
×
1075
                );
1076
                OutputManagerStorageError::ConversionError {
×
1077
                    reason: format!("Spending key id could not be converted from string ({e})"),
×
1078
                }
×
1079
            })?,
×
1080
            features,
×
1081
            TariScript::from_bytes(self.script.as_slice())?,
×
1082
            ExecutionStack::from_bytes(self.input_data.as_slice())?,
×
1083
            TariKeyId::from_str(&self.script_private_key).map_err(|e| {
×
1084
                error!(
×
1085
                    target: LOG_TARGET,
×
1086
                    "Could not create script private key id from stored string ({e})"
×
1087
                );
1088
                OutputManagerStorageError::ConversionError {
×
1089
                    reason: format!("Script private key id could not be converted from string ({e})"),
×
1090
                }
×
1091
            })?,
×
1092
            CompressedPublicKey::from_vec(&self.sender_offset_public_key).map_err(|_| {
×
1093
                error!(
×
1094
                    target: LOG_TARGET,
×
1095
                    "Could not create PublicKey from stored bytes, They might be encrypted"
×
1096
                );
1097
                OutputManagerStorageError::ConversionError {
×
1098
                    reason: "PrivateKey could not be converted from bytes".to_string(),
×
1099
                }
×
1100
            })?,
×
1101
            ComAndPubSignature::new(
×
1102
                CompressedCommitment::from_vec(&self.metadata_signature_ephemeral_commitment).map_err(|_| {
×
1103
                    error!(
×
1104
                        target: LOG_TARGET,
×
1105
                        "Could not create Commitment from stored bytes, They might be encrypted"
×
1106
                    );
1107
                    OutputManagerStorageError::ConversionError {
×
1108
                        reason: "Commitment could not be converted from bytes".to_string(),
×
1109
                    }
×
1110
                })?,
×
1111
                CompressedPublicKey::from_vec(&self.metadata_signature_ephemeral_pubkey).map_err(|_| {
×
1112
                    error!(
×
1113
                        target: LOG_TARGET,
×
1114
                        "Could not create PublicKey from stored bytes, They might be encrypted"
×
1115
                    );
1116
                    OutputManagerStorageError::ConversionError {
×
1117
                        reason: "PublicKey could not be converted from bytes".to_string(),
×
1118
                    }
×
1119
                })?,
×
1120
                PrivateKey::from_vec(&self.metadata_signature_u_a).map_err(|_| {
×
1121
                    error!(
×
1122
                        target: LOG_TARGET,
×
1123
                        "Could not create PrivateKey from stored bytes, They might be encrypted"
×
1124
                    );
1125
                    OutputManagerStorageError::ConversionError {
×
1126
                        reason: "PrivateKey could not be converted from bytes".to_string(),
×
1127
                    }
×
1128
                })?,
×
1129
                PrivateKey::from_vec(&self.metadata_signature_u_x).map_err(|_| {
×
1130
                    error!(
×
1131
                        target: LOG_TARGET,
×
1132
                        "Could not create PrivateKey from stored bytes, They might be encrypted"
×
1133
                    );
1134
                    OutputManagerStorageError::ConversionError {
×
1135
                        reason: "PrivateKey could not be converted from bytes".to_string(),
×
1136
                    }
×
1137
                })?,
×
1138
                PrivateKey::from_vec(&self.metadata_signature_u_y).map_err(|_| {
×
1139
                    error!(
×
1140
                        target: LOG_TARGET,
×
1141
                        "Could not create PrivateKey from stored bytes, They might be encrypted"
×
1142
                    );
1143
                    OutputManagerStorageError::ConversionError {
×
1144
                        reason: "PrivateKey could not be converted from bytes".to_string(),
×
1145
                    }
×
1146
                })?,
×
1147
            ),
1148
            self.script_lock_height as u64,
×
1149
            covenant,
×
1150
            encrypted_data,
×
1151
            MicroMinotari::from(self.minimum_value_promise as u64),
×
1152
            match self.rangeproof {
×
1153
                Some(bytes) => Some(RangeProof::from_canonical_bytes(&bytes)?),
×
1154
                None => None,
×
1155
            },
1156
            payment_id.clone(),
×
1157
            hash,
×
1158
            commitment.clone(),
×
1159
        );
1160

1161
        let spending_priority = SpendingPriority::try_from(self.spending_priority as u32).map_err(|e| {
×
1162
            OutputManagerStorageError::ConversionError {
×
1163
                reason: format!("Could not convert spending priority from i32: {e}"),
×
1164
            }
×
1165
        })?;
×
1166
        let mined_in_block = match self.mined_in_block {
×
1167
            Some(v) => v.try_into().ok(),
×
1168
            None => None,
×
1169
        };
1170
        let marked_deleted_in_block = match self.marked_deleted_in_block {
×
1171
            Some(v) => v.try_into().ok(),
×
1172
            None => None,
×
1173
        };
1174
        Ok(DbWalletOutput {
1175
            commitment,
×
1176
            wallet_output,
×
1177
            hash,
×
1178
            status: self.status.try_into()?,
×
1179
            mined_height: self.mined_height.map(|mh| mh as u64),
×
1180
            mined_in_block,
×
1181
            mined_timestamp: self.mined_timestamp.map(|mt| mt.and_utc()),
×
1182
            marked_deleted_at_height: self.marked_deleted_at_height.map(|d| d as u64),
×
1183
            marked_deleted_in_block,
×
1184
            spending_priority,
×
1185
            source: self.source.try_into()?,
×
1186
            received_in_tx_id: self.received_in_tx_id.map(|d| (d as u64).into()),
×
1187
            spent_in_tx_id: self.spent_in_tx_id.map(|d| (d as u64).into()),
×
1188
            payment_id,
×
1189
        })
1190
    }
×
1191
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc