clementine_core/database/
verifier.rs

1//! # Verifier Related Database Operations
2//!
3//! This module includes database functions which are mainly used by a verifier.
4
5use std::ops::DerefMut;
6
7use super::{
8    wrapper::{BlockHashDB, TxidDB, XOnlyPublicKeyDB},
9    Database, DatabaseTransaction,
10};
11use crate::execute_query_with_tx;
12use bitcoin::{BlockHash, OutPoint, Txid, XOnlyPublicKey};
13use clementine_errors::BridgeError;
14use eyre::Context;
15use sqlx::QueryBuilder;
16
17impl Database {
18    /// Returns the last deposit index.
19    /// If no deposits exist, returns None
20    pub async fn get_last_deposit_idx(
21        &self,
22        tx: Option<DatabaseTransaction<'_>>,
23    ) -> Result<Option<u32>, BridgeError> {
24        let query = sqlx::query_as::<_, (i32,)>("SELECT COALESCE(MAX(idx), -1) FROM withdrawals");
25        let result = execute_query_with_tx!(self.connection, tx, query, fetch_one)?;
26        if result.0 == -1 {
27            Ok(None)
28        } else {
29            Ok(Some(result.0 as u32))
30        }
31    }
32
33    /// Returns the last withdrawal index where withdrawal_utxo_txid exists.
34    /// If no withdrawals with UTXOs exist, returns None.
35    pub async fn get_last_withdrawal_idx(
36        &self,
37        tx: Option<DatabaseTransaction<'_>>,
38    ) -> Result<Option<u32>, BridgeError> {
39        let query = sqlx::query_as::<_, (i32,)>(
40            "SELECT COALESCE(MAX(idx), -1) FROM withdrawals WHERE withdrawal_utxo_txid IS NOT NULL",
41        );
42        let result = execute_query_with_tx!(self.connection, tx, query, fetch_one)?;
43        if result.0 == -1 {
44            Ok(None)
45        } else {
46            Ok(Some(result.0 as u32))
47        }
48    }
49
50    pub async fn upsert_move_to_vault_txid_from_citrea_deposit(
51        &self,
52        tx: Option<DatabaseTransaction<'_>>,
53        citrea_idx: u32,
54        move_to_vault_txid: &Txid,
55    ) -> Result<(), BridgeError> {
56        let query = sqlx::query(
57            "INSERT INTO withdrawals (idx, move_to_vault_txid)
58             VALUES ($1, $2)
59             ON CONFLICT (idx) DO UPDATE
60             SET move_to_vault_txid = $2",
61        )
62        .bind(i32::try_from(citrea_idx).wrap_err("Failed to convert citrea index to i32")?)
63        .bind(TxidDB(*move_to_vault_txid));
64
65        execute_query_with_tx!(self.connection, tx, query, execute)?;
66        Ok(())
67    }
68
69    pub async fn get_move_to_vault_txid_from_citrea_deposit(
70        &self,
71        tx: Option<DatabaseTransaction<'_>>,
72        citrea_idx: u32,
73    ) -> Result<Option<Txid>, BridgeError> {
74        let query = sqlx::query_as::<_, (TxidDB,)>(
75            "SELECT move_to_vault_txid FROM withdrawals WHERE idx = $1",
76        )
77        .bind(i32::try_from(citrea_idx).wrap_err("Failed to convert citrea index to i32")?);
78
79        let result: Option<(TxidDB,)> =
80            execute_query_with_tx!(self.connection, tx, query, fetch_optional)?;
81
82        Ok(result.map(|(move_to_vault_txid,)| move_to_vault_txid.0))
83    }
84
85    pub async fn update_replacement_deposit_move_txid(
86        &self,
87        tx: DatabaseTransaction<'_>,
88        idx: u32,
89        new_move_txid: Txid,
90    ) -> Result<(), BridgeError> {
91        let query = sqlx::query(
92            "UPDATE withdrawals
93             SET move_to_vault_txid = $2
94             WHERE idx = $1
95             RETURNING idx",
96        )
97        .bind(i32::try_from(idx).wrap_err("Failed to convert idx to i32")?)
98        .bind(TxidDB(new_move_txid))
99        .fetch_optional(tx.deref_mut())
100        .await?;
101
102        if query.is_none() {
103            return Err(eyre::eyre!("Replacement move txid not found: {}", idx).into());
104        }
105        Ok(())
106    }
107
108    pub async fn update_withdrawal_utxo_from_citrea_withdrawal(
109        &self,
110        tx: Option<DatabaseTransaction<'_>>,
111        citrea_idx: u32,
112        withdrawal_utxo: OutPoint,
113        withdrawal_batch_proof_bitcoin_block_height: u32,
114    ) -> Result<(), BridgeError> {
115        let query = sqlx::query(
116            "UPDATE withdrawals
117             SET withdrawal_utxo_txid = $2,
118                 withdrawal_utxo_vout = $3,
119                 withdrawal_batch_proof_bitcoin_block_height = $4
120             WHERE idx = $1",
121        )
122        .bind(i32::try_from(citrea_idx).wrap_err("Failed to convert citrea index to i32")?)
123        .bind(TxidDB(withdrawal_utxo.txid))
124        .bind(
125            i32::try_from(withdrawal_utxo.vout)
126                .wrap_err("Failed to convert withdrawal utxo vout to i32")?,
127        )
128        .bind(
129            i32::try_from(withdrawal_batch_proof_bitcoin_block_height)
130                .wrap_err("Failed to convert withdrawal batch proof bitcoin block height to i32")?,
131        );
132
133        execute_query_with_tx!(self.connection, tx, query, execute)?;
134        Ok(())
135    }
136
137    /// For the given deposit index, returns the withdrawal utxo associated with it
138    /// If there is no withdrawal utxo set for the deposit, an error is returned
139    pub async fn get_withdrawal_utxo_from_citrea_withdrawal(
140        &self,
141        tx: Option<DatabaseTransaction<'_>>,
142        citrea_idx: u32,
143    ) -> Result<OutPoint, BridgeError> {
144        let query = sqlx::query_as::<_, (Option<TxidDB>, Option<i32>)>(
145            "SELECT w.withdrawal_utxo_txid, w.withdrawal_utxo_vout
146             FROM withdrawals w
147             WHERE w.idx = $1",
148        )
149        .bind(i32::try_from(citrea_idx).wrap_err("Failed to convert citrea index to i32")?);
150
151        let results = execute_query_with_tx!(self.connection, tx, query, fetch_optional)?;
152
153        match results {
154            None => Err(eyre::eyre!("Deposit with id {} is not set", citrea_idx).into()),
155            Some((txid, vout)) => match (txid, vout) {
156                (Some(txid), Some(vout)) => Ok(OutPoint {
157                    txid: txid.0,
158                    vout: u32::try_from(vout)
159                        .wrap_err("Failed to convert withdrawal utxo vout to u32")?,
160                }),
161                _ => {
162                    Err(eyre::eyre!("Withdrawal utxo is not set for deposit {}", citrea_idx).into())
163                }
164            },
165        }
166    }
167
168    /// Returns the withdrawal indexes and their spending txid for the given
169    /// block id.
170    pub async fn get_payout_txs_for_withdrawal_utxos(
171        &self,
172        tx: Option<DatabaseTransaction<'_>>,
173        block_id: u32,
174    ) -> Result<Vec<(u32, Txid)>, BridgeError> {
175        let query = sqlx::query_as::<_, (i32, TxidDB)>(
176            "SELECT w.idx, bsu.spending_txid
177             FROM withdrawals w
178             JOIN bitcoin_syncer_spent_utxos bsu
179                ON bsu.txid = w.withdrawal_utxo_txid
180                AND bsu.vout = w.withdrawal_utxo_vout
181             WHERE bsu.block_id = $1",
182        )
183        .bind(i32::try_from(block_id).wrap_err("Failed to convert block id to i32")?);
184
185        let results = execute_query_with_tx!(self.connection, tx, query, fetch_all)?;
186
187        results
188            .into_iter()
189            .map(|(idx, txid)| {
190                Ok((
191                    u32::try_from(idx).wrap_err("Failed to convert withdrawal index to u32")?,
192                    txid.0,
193                ))
194            })
195            .collect()
196    }
197
198    /// Sets the given payout txs' txid and operator index for the given index.
199    pub async fn update_payout_txs_and_payer_operator_xonly_pk(
200        &self,
201        tx: Option<DatabaseTransaction<'_>>,
202        payout_txs_and_payer_operator_xonly_pk: Vec<(
203            u32,
204            Txid,
205            Option<XOnlyPublicKey>,
206            bitcoin::BlockHash,
207        )>,
208    ) -> Result<(), BridgeError> {
209        if payout_txs_and_payer_operator_xonly_pk.is_empty() {
210            return Ok(());
211        }
212        // Convert all values first, propagating any errors
213        let converted_values: Result<Vec<_>, BridgeError> = payout_txs_and_payer_operator_xonly_pk
214            .iter()
215            .map(|(idx, txid, operator_xonly_pk, block_hash)| {
216                Ok((
217                    i32::try_from(*idx).wrap_err("Failed to convert payout index to i32")?,
218                    TxidDB(*txid),
219                    operator_xonly_pk.map(XOnlyPublicKeyDB),
220                    BlockHashDB(*block_hash),
221                ))
222            })
223            .collect();
224        let converted_values = converted_values?;
225
226        let mut query_builder = QueryBuilder::new(
227            "UPDATE withdrawals AS w SET
228                payout_txid = c.payout_txid,
229                payout_payer_operator_xonly_pk = c.payout_payer_operator_xonly_pk,
230                payout_tx_blockhash = c.payout_tx_blockhash
231                FROM (",
232        );
233
234        query_builder.push_values(
235            converted_values.into_iter(),
236            |mut b, (idx, txid, operator_xonly_pk, block_hash)| {
237                b.push_bind(idx)
238                    .push_bind(txid)
239                    .push_bind(operator_xonly_pk)
240                    .push_bind(block_hash);
241            },
242        );
243
244        query_builder
245            .push(") AS c(idx, payout_txid, payout_payer_operator_xonly_pk, payout_tx_blockhash) WHERE w.idx = c.idx");
246
247        let query = query_builder.build();
248        execute_query_with_tx!(self.connection, tx, query, execute)?;
249
250        Ok(())
251    }
252
253    pub async fn get_payout_info_from_move_txid(
254        &self,
255        tx: Option<DatabaseTransaction<'_>>,
256        move_to_vault_txid: Txid,
257    ) -> Result<Option<(Option<XOnlyPublicKey>, BlockHash, Txid, i32)>, BridgeError> {
258        let query = sqlx::query_as::<_, (Option<XOnlyPublicKeyDB>, BlockHashDB, TxidDB, i32)>(
259            "SELECT w.payout_payer_operator_xonly_pk, w.payout_tx_blockhash, w.payout_txid, w.idx
260             FROM withdrawals w
261             WHERE w.move_to_vault_txid = $1
262               AND w.payout_txid IS NOT NULL
263               AND w.payout_tx_blockhash IS NOT NULL",
264        )
265        .bind(TxidDB(move_to_vault_txid));
266
267        let result: Option<(Option<XOnlyPublicKeyDB>, BlockHashDB, TxidDB, i32)> =
268            execute_query_with_tx!(self.connection, tx, query, fetch_optional)?;
269
270        result
271            .map(|(operator_xonly_pk, block_hash, txid, deposit_idx)| {
272                Ok((
273                    operator_xonly_pk.map(|pk| pk.0),
274                    block_hash.0,
275                    txid.0,
276                    deposit_idx,
277                ))
278            })
279            .transpose()
280    }
281
282    pub async fn get_first_unhandled_payout_by_operator_xonly_pk(
283        &self,
284        tx: Option<DatabaseTransaction<'_>>,
285        operator_xonly_pk: XOnlyPublicKey,
286    ) -> Result<Option<(u32, Txid, BlockHash)>, BridgeError> {
287        let query = sqlx::query_as::<_, (i32, Option<TxidDB>, Option<BlockHashDB>)>(
288            "SELECT w.idx, w.move_to_vault_txid, w.payout_tx_blockhash
289             FROM withdrawals w
290             WHERE w.payout_txid IS NOT NULL
291                AND w.is_payout_handled = FALSE
292                AND w.payout_payer_operator_xonly_pk = $1
293                ORDER BY w.idx ASC
294             LIMIT 1",
295        )
296        .bind(XOnlyPublicKeyDB(operator_xonly_pk));
297
298        let results = execute_query_with_tx!(self.connection, tx, query, fetch_optional)?;
299
300        results
301            .map(|(citrea_idx, move_to_vault_txid, payout_tx_blockhash)| {
302                Ok((
303                    u32::try_from(citrea_idx).wrap_err("Failed to convert citrea index to u32")?,
304                    move_to_vault_txid
305                        .expect("move_to_vault_txid Must be Some")
306                        .0,
307                    payout_tx_blockhash
308                        .expect("payout_tx_blockhash Must be Some")
309                        .0,
310                ))
311            })
312            .transpose()
313    }
314
315    pub async fn get_payer_xonly_pk_blockhash_and_kickoff_txid_from_deposit_id(
316        &self,
317        tx: Option<DatabaseTransaction<'_>>,
318        deposit_id: u32,
319    ) -> Result<(Option<XOnlyPublicKey>, Option<BlockHash>, Option<Txid>), BridgeError> {
320        let query = sqlx::query_as::<
321            _,
322            (
323                Option<XOnlyPublicKeyDB>,
324                Option<BlockHashDB>,
325                Option<TxidDB>,
326            ),
327        >(
328            "SELECT w.payout_payer_operator_xonly_pk, w.payout_tx_blockhash, w.kickoff_txid
329             FROM withdrawals w
330             INNER JOIN deposits d ON d.move_to_vault_txid = w.move_to_vault_txid
331             WHERE d.deposit_id = $1",
332        )
333        .bind(i32::try_from(deposit_id).wrap_err("Failed to convert deposit id to i32")?);
334
335        let result: (
336            Option<XOnlyPublicKeyDB>,
337            Option<BlockHashDB>,
338            Option<TxidDB>,
339        ) = execute_query_with_tx!(self.connection, tx, query, fetch_one)?;
340
341        Ok((
342            result.0.map(|pk| pk.0),
343            result.1.map(|block_hash| block_hash.0),
344            result.2.map(|txid| txid.0),
345        ))
346    }
347
348    pub async fn mark_payout_handled(
349        &self,
350        tx: Option<DatabaseTransaction<'_>>,
351        citrea_idx: u32,
352        kickoff_txid: Txid,
353    ) -> Result<(), BridgeError> {
354        let query = sqlx::query(
355            "UPDATE withdrawals SET is_payout_handled = TRUE, kickoff_txid = $2 WHERE idx = $1",
356        )
357        .bind(i32::try_from(citrea_idx).wrap_err("Failed to convert citrea index to i32")?)
358        .bind(TxidDB(kickoff_txid));
359
360        execute_query_with_tx!(self.connection, tx, query, execute)?;
361        Ok(())
362    }
363
364    pub async fn get_handled_payout_kickoff_txid(
365        &self,
366        tx: Option<DatabaseTransaction<'_>>,
367        payout_txid: Txid,
368    ) -> Result<Option<Txid>, BridgeError> {
369        let query = sqlx::query_as::<_, (Option<TxidDB>,)>(
370            "SELECT kickoff_txid FROM withdrawals WHERE payout_txid = $1 AND is_payout_handled = TRUE",
371        )
372        .bind(TxidDB(payout_txid));
373
374        let result: Option<(Option<TxidDB>,)> =
375            execute_query_with_tx!(self.connection, tx, query, fetch_optional)?;
376
377        Ok(result
378            .map(|(kickoff_txid,)| kickoff_txid.expect("If handled, kickoff_txid must exist").0))
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use crate::{
385        database::Database,
386        test::common::{create_test_config_with_thread_name, generate_random_xonly_pk},
387    };
388    use bitcoin::{hashes::Hash, BlockHash, Txid};
389
390    #[tokio::test]
391    async fn update_get_payout_txs_from_citrea_withdrawal() {
392        let config = create_test_config_with_thread_name().await;
393        let db = Database::new(&config).await.unwrap();
394
395        let txid = Txid::from_byte_array([0x45; 32]);
396        let index = 0x1F;
397        let operator_xonly_pk = generate_random_xonly_pk();
398        let utxo = bitcoin::OutPoint {
399            txid: bitcoin::Txid::from_byte_array([0x45; 32]),
400            vout: 0,
401        };
402
403        let mut dbtx = db.begin_transaction().await.unwrap();
404
405        let block_id = db
406            .insert_block_info(
407                Some(&mut dbtx),
408                &BlockHash::all_zeros(),
409                &BlockHash::all_zeros(),
410                utxo.vout,
411            )
412            .await
413            .unwrap();
414        db.insert_txid_to_block(&mut dbtx, block_id, &txid)
415            .await
416            .unwrap();
417        db.insert_spent_utxo(&mut dbtx, block_id, &txid, &utxo.txid, utxo.vout.into())
418            .await
419            .unwrap();
420
421        assert!(db
422            .get_withdrawal_utxo_from_citrea_withdrawal(Some(&mut dbtx), index)
423            .await
424            .is_err());
425        db.upsert_move_to_vault_txid_from_citrea_deposit(Some(&mut dbtx), index, &txid)
426            .await
427            .unwrap();
428        db.update_withdrawal_utxo_from_citrea_withdrawal(Some(&mut dbtx), index, utxo, block_id)
429            .await
430            .unwrap();
431
432        let block_hash = BlockHash::all_zeros();
433
434        db.update_payout_txs_and_payer_operator_xonly_pk(
435            Some(&mut dbtx),
436            vec![(index, txid, Some(operator_xonly_pk), block_hash)],
437        )
438        .await
439        .unwrap();
440
441        let txs = db
442            .get_payout_txs_for_withdrawal_utxos(Some(&mut dbtx), block_id)
443            .await
444            .unwrap();
445
446        assert_eq!(txs.len(), 1);
447        assert_eq!(txs[0].0, index);
448        assert_eq!(txs[0].1, txid);
449
450        let withdrawal_utxo = db
451            .get_withdrawal_utxo_from_citrea_withdrawal(Some(&mut dbtx), index)
452            .await
453            .unwrap();
454        assert_eq!(withdrawal_utxo, utxo);
455
456        let move_txid = db
457            .get_move_to_vault_txid_from_citrea_deposit(Some(&mut dbtx), index)
458            .await
459            .unwrap()
460            .unwrap();
461        assert_eq!(move_txid, txid);
462
463        // Test payout info retrieval with Some operator xonly pk
464        let payout_info = db
465            .get_payout_info_from_move_txid(Some(&mut dbtx), move_txid)
466            .await
467            .unwrap()
468            .unwrap();
469        assert_eq!(payout_info.0, Some(operator_xonly_pk));
470        assert_eq!(payout_info.1, block_hash);
471        assert_eq!(payout_info.2, txid);
472        assert_eq!(payout_info.3, index as i32);
473
474        // Test with None operator xonly pk (optimistic payout or incorrect payout)
475        let index2 = 0x2F;
476        let txid2 = Txid::from_byte_array([0x55; 32]);
477        let utxo2 = bitcoin::OutPoint {
478            txid: bitcoin::Txid::from_byte_array([0x55; 32]),
479            vout: 1,
480        };
481
482        db.insert_txid_to_block(&mut dbtx, block_id, &txid2)
483            .await
484            .unwrap();
485        db.insert_spent_utxo(&mut dbtx, block_id, &txid2, &utxo2.txid, utxo2.vout.into())
486            .await
487            .unwrap();
488
489        db.upsert_move_to_vault_txid_from_citrea_deposit(Some(&mut dbtx), index2, &txid2)
490            .await
491            .unwrap();
492        db.update_withdrawal_utxo_from_citrea_withdrawal(Some(&mut dbtx), index2, utxo2, block_id)
493            .await
494            .unwrap();
495
496        // Set payout with None operator xonly pk
497        db.update_payout_txs_and_payer_operator_xonly_pk(
498            Some(&mut dbtx),
499            vec![(index2, txid2, None, block_hash)],
500        )
501        .await
502        .unwrap();
503
504        // Test payout info retrieval with None operator xonly pk
505        let payout_info2 = db
506            .get_payout_info_from_move_txid(Some(&mut dbtx), txid2)
507            .await
508            .unwrap()
509            .unwrap();
510        assert_eq!(payout_info2.0, None); // No operator xonly pk
511        assert_eq!(payout_info2.1, block_hash);
512        assert_eq!(payout_info2.2, txid2);
513        assert_eq!(payout_info2.3, index2 as i32);
514
515        // Verify we now have 2 payout transactions
516        let all_txs = db
517            .get_payout_txs_for_withdrawal_utxos(Some(&mut dbtx), block_id)
518            .await
519            .unwrap();
520        assert_eq!(all_txs.len(), 2);
521    }
522}