Skip to main content

cdk_sql_common/mint/
proofs.rs

1//! Proofs database implementation
2
3use std::collections::HashMap;
4use std::str::FromStr;
5
6use async_trait::async_trait;
7use cdk_common::database::mint::Acquired;
8use cdk_common::database::{self, Error, MintProofsDatabase};
9use cdk_common::mint::{Operation, ProofsWithState};
10use cdk_common::nut00::ProofsMethods;
11use cdk_common::quote_id::QuoteId;
12use cdk_common::secret::Secret;
13use cdk_common::util::unix_time;
14use cdk_common::{Amount, Id, Proof, Proofs, PublicKey, State};
15
16use super::{SQLMintDatabase, SQLTransaction};
17use crate::database::DatabaseExecutor;
18use crate::pool::DatabasePool;
19use crate::stmt::{query, Column};
20use crate::{column_as_nullable_string, column_as_number, column_as_string, unpack_into};
21
22pub(super) async fn get_current_states<C>(
23    conn: &C,
24    ys: &[PublicKey],
25    for_update: bool,
26) -> Result<HashMap<PublicKey, State>, Error>
27where
28    C: DatabaseExecutor + Send + Sync,
29{
30    let for_update_clause = if for_update { "FOR UPDATE" } else { "" };
31
32    query(&format!(
33        r#"SELECT y, state FROM proof WHERE y IN (:ys) ORDER BY y {}"#,
34        for_update_clause
35    ))?
36    .bind_vec("ys", ys.iter().map(|y| y.to_bytes().to_vec()).collect())?
37    .fetch_all(conn)
38    .await?
39    .into_iter()
40    .map(|row| {
41        Ok((
42            column_as_string!(&row[0], PublicKey::from_hex, PublicKey::from_slice),
43            column_as_string!(&row[1], State::from_str),
44        ))
45    })
46    .collect::<Result<HashMap<_, _>, _>>()
47}
48
49pub(super) fn sql_row_to_proof(row: Vec<Column>) -> Result<Proof, Error> {
50    unpack_into!(
51        let (
52            amount,
53            keyset_id,
54            secret,
55            c,
56            witness
57        ) = row
58    );
59
60    let amount: u64 = column_as_number!(amount);
61    Ok(Proof {
62        amount: Amount::from(amount),
63        keyset_id: column_as_string!(keyset_id, Id::from_str),
64        secret: column_as_string!(secret, Secret::from_str),
65        c: column_as_string!(c, PublicKey::from_hex, PublicKey::from_slice),
66        witness: column_as_nullable_string!(witness).and_then(|w| serde_json::from_str(&w).ok()),
67        dleq: None,
68        p2pk_e: None,
69    })
70}
71
72pub(super) fn sql_row_to_proof_with_state(row: Vec<Column>) -> Result<(Proof, State), Error> {
73    unpack_into!(
74        let (
75            keyset_id, amount, secret, c, witness, state
76        ) = row
77    );
78
79    let amount: u64 = column_as_number!(amount);
80    let state = column_as_nullable_string!(state)
81        .and_then(|s| State::from_str(&s).ok())
82        .unwrap_or(State::Pending);
83
84    Ok((
85        Proof {
86            amount: Amount::from(amount),
87            keyset_id: column_as_string!(keyset_id, Id::from_str, Id::from_bytes),
88            secret: column_as_string!(secret, Secret::from_str),
89            c: column_as_string!(c, PublicKey::from_hex, PublicKey::from_slice),
90            witness: column_as_nullable_string!(witness)
91                .and_then(|w| serde_json::from_str(&w).ok()),
92            dleq: None,
93            p2pk_e: None,
94        },
95        state,
96    ))
97}
98
99pub(super) fn sql_row_to_hashmap_amount(row: Vec<Column>) -> Result<(Id, Amount), Error> {
100    unpack_into!(
101        let (
102            keyset_id, amount
103        ) = row
104    );
105
106    let amount: u64 = column_as_number!(amount);
107    Ok((
108        column_as_string!(keyset_id, Id::from_str, Id::from_bytes),
109        Amount::from(amount),
110    ))
111}
112
113#[async_trait]
114impl<RM> database::MintProofsTransaction for SQLTransaction<RM>
115where
116    RM: DatabasePool + 'static,
117{
118    type Err = Error;
119
120    /// Adds proofs to the database with initial state `Unspent`.
121    ///
122    /// This method first checks if any of the proofs already exist in the database.
123    /// If a proof exists and is spent, returns [`Error::AttemptUpdateSpentProof`].
124    /// If a proof exists in any other state, returns [`Error::Duplicate`].
125    ///
126    /// On success, returns the proofs wrapped in [`Acquired<ProofsWithState>`] with
127    /// state set to `Unspent`, indicating the rows are locked for the duration of
128    /// the transaction.
129    async fn add_proofs(
130        &mut self,
131        proofs: Proofs,
132        quote_id: Option<QuoteId>,
133        operation: &Operation,
134    ) -> Result<Acquired<ProofsWithState>, Self::Err> {
135        let current_time = unix_time();
136
137        let mut ordered_proofs = proofs
138            .iter()
139            .map(|proof| Ok((proof.y()?.to_bytes().to_vec(), proof)))
140            .collect::<Result<Vec<_>, Error>>()?;
141        ordered_proofs.sort_unstable_by(|(left_y, _), (right_y, _)| left_y.cmp(right_y));
142
143        // Check any previous proof, this query should return None in order to proceed storing
144        // Any result here would error
145        match query(r#"SELECT state FROM proof WHERE y IN (:ys) ORDER BY y LIMIT 1 FOR UPDATE"#)?
146            .bind_vec(
147                "ys",
148                ordered_proofs.iter().map(|(y, _)| y.clone()).collect(),
149            )?
150            .pluck(&self.inner)
151            .await?
152            .map(|state| Ok::<_, Error>(column_as_string!(&state, State::from_str)))
153            .transpose()?
154        {
155            Some(State::Spent) => Err(database::Error::AttemptUpdateSpentProof),
156            Some(_) => Err(database::Error::Duplicate),
157            None => Ok(()), // no previous record
158        }?;
159
160        for (y, proof) in ordered_proofs {
161            query(
162                r#"
163                  INSERT INTO proof
164                  (y, amount, keyset_id, secret, c, witness, state, quote_id, created_time, operation_kind, operation_id)
165                  VALUES
166                  (:y, :amount, :keyset_id, :secret, :c, :witness, :state, :quote_id, :created_time, :operation_kind, :operation_id)
167                  "#,
168            )?
169            .bind("y", y)
170            .bind("amount", proof.amount.to_i64())
171            .bind("keyset_id", proof.keyset_id.to_string())
172            .bind("secret", proof.secret.to_string())
173            .bind("c", proof.c.to_bytes().to_vec())
174            .bind(
175                "witness",
176                proof.witness.clone().and_then(|w| serde_json::to_string(&w).inspect_err(|e| tracing::error!("Failed to serialize witness: {:?}", e)).ok()),
177            )
178            .bind("state", "UNSPENT".to_string())
179            .bind("quote_id", quote_id.clone().map(|q| q.to_string()))
180            .bind("created_time", current_time as i64)
181            .bind("operation_kind", operation.kind().to_string())
182            .bind("operation_id", operation.id().to_string())
183            .execute(&self.inner)
184            .await?;
185        }
186
187        Ok(ProofsWithState::new(proofs, State::Unspent).into())
188    }
189
190    /// Updates all proofs to the given state in the database.
191    ///
192    /// Also updates the `state` field on the [`ProofsWithState`] wrapper to reflect
193    /// the new state after the database update succeeds.
194    ///
195    /// When the new state is `Spent`, this method also updates the `keyset_amounts`
196    /// table to track the total redeemed amount per keyset for analytics purposes.
197    ///
198    /// # Prerequisites
199    ///
200    /// The proofs must have been previously acquired via `add_proofs`
201    /// or `get_proofs` to ensure they are locked within the current transaction.
202    async fn update_proofs_state(
203        &mut self,
204        proofs: &mut Acquired<ProofsWithState>,
205        new_state: State,
206    ) -> Result<(), Self::Err> {
207        let ys = proofs.ys()?;
208
209        query(r#"UPDATE proof SET state = :new_state WHERE y IN (:ys)"#)?
210            .bind("new_state", new_state.to_string())
211            .bind_vec("ys", ys.iter().map(|y| y.to_bytes().to_vec()).collect())?
212            .execute(&self.inner)
213            .await?;
214
215        if new_state == State::Spent {
216            query(
217                    r#"
218                    INSERT INTO keyset_amounts (keyset_id, total_issued, total_redeemed)
219                    SELECT keyset_id, 0, COALESCE(SUM(amount), 0)
220                    FROM proof
221                    WHERE y IN (:ys)
222                    GROUP BY keyset_id
223                    ORDER BY keyset_id
224                    ON CONFLICT (keyset_id)
225                    DO UPDATE SET total_redeemed = keyset_amounts.total_redeemed + EXCLUDED.total_redeemed
226                    "#,
227                )?
228                .bind_vec("ys", ys.iter().map(|y| y.to_bytes().to_vec()).collect())?
229                .execute(&self.inner)
230                .await?;
231        }
232
233        proofs.state = new_state;
234
235        Ok(())
236    }
237
238    async fn remove_proofs(
239        &mut self,
240        ys: &[PublicKey],
241        _quote_id: Option<QuoteId>,
242    ) -> Result<(), Self::Err> {
243        // Acquire all rows that can be deleted in primary-key order. This keeps
244        // concurrent proof cleanup and finalization from locking batches in
245        // different orders.
246        query(
247            r#"
248            SELECT y
249            FROM proof
250            WHERE y IN (:ys) AND state NOT IN (:exclude_state)
251            ORDER BY y
252            FOR UPDATE
253            "#,
254        )?
255        .bind_vec("ys", ys.iter().map(|y| y.to_bytes().to_vec()).collect())?
256        .bind_vec("exclude_state", vec![State::Spent.to_string()])?
257        .fetch_all(&self.inner)
258        .await?;
259
260        let total_deleted = query(
261            r#"
262            DELETE FROM proof WHERE y IN (:ys) AND state NOT IN (:exclude_state)
263            "#,
264        )?
265        .bind_vec("ys", ys.iter().map(|y| y.to_bytes().to_vec()).collect())?
266        .bind_vec("exclude_state", vec![State::Spent.to_string()])?
267        .execute(&self.inner)
268        .await?;
269
270        if total_deleted != ys.len() {
271            // Query current states to provide detailed logging
272            let current_states = get_current_states(&self.inner, ys, true).await?;
273
274            let missing_count = ys.len() - current_states.len();
275            let spent_count = current_states
276                .values()
277                .filter(|s| **s == State::Spent)
278                .count();
279
280            if missing_count > 0 {
281                tracing::warn!(
282                    "remove_proofs: {} of {} proofs do not exist in database (already removed?)",
283                    missing_count,
284                    ys.len()
285                );
286            }
287
288            if spent_count > 0 {
289                tracing::warn!(
290                    "remove_proofs: {} of {} proofs are in Spent state and cannot be removed",
291                    spent_count,
292                    ys.len()
293                );
294            }
295
296            tracing::debug!(
297                "remove_proofs details: requested={}, deleted={}, missing={}, spent={}",
298                ys.len(),
299                total_deleted,
300                missing_count,
301                spent_count
302            );
303
304            return Err(Self::Err::AttemptRemoveSpentProof);
305        }
306
307        Ok(())
308    }
309
310    async fn get_proof_ys_by_quote_id(
311        &mut self,
312        quote_id: &QuoteId,
313    ) -> Result<Vec<PublicKey>, Self::Err> {
314        Ok(query(
315            r#"
316            SELECT
317                amount,
318                keyset_id,
319                secret,
320                c,
321                witness
322            FROM
323                proof
324            WHERE
325                quote_id = :quote_id
326            ORDER BY y
327            FOR UPDATE
328            "#,
329        )?
330        .bind("quote_id", quote_id.to_string())
331        .fetch_all(&self.inner)
332        .await?
333        .into_iter()
334        .map(sql_row_to_proof)
335        .collect::<Result<Vec<Proof>, _>>()?
336        .ys()?)
337    }
338
339    async fn get_proof_ys_by_operation_id(
340        &mut self,
341        operation_id: &uuid::Uuid,
342    ) -> Result<Vec<PublicKey>, Self::Err> {
343        Ok(query(
344            r#"
345            SELECT
346                y
347            FROM
348                proof
349            WHERE
350                operation_id = :operation_id
351            "#,
352        )?
353        .bind("operation_id", operation_id.to_string())
354        .fetch_all(&self.inner)
355        .await?
356        .into_iter()
357        .map(|row| -> Result<PublicKey, Error> {
358            Ok(column_as_string!(
359                &row[0],
360                PublicKey::from_hex,
361                PublicKey::from_slice
362            ))
363        })
364        .collect::<Result<Vec<_>, _>>()?)
365    }
366
367    async fn get_proofs(
368        &mut self,
369        ys: &[PublicKey],
370    ) -> Result<Acquired<ProofsWithState>, Self::Err> {
371        let rows = query(
372            r#"
373             SELECT
374                 keyset_id,
375                 amount,
376                 secret,
377                 c,
378                 witness,
379                 state
380             FROM
381                 proof
382             WHERE
383                 y IN (:ys)
384             ORDER BY y
385             FOR UPDATE
386             "#,
387        )?
388        .bind_vec("ys", ys.iter().map(|y| y.to_bytes().to_vec()).collect())?
389        .fetch_all(&self.inner)
390        .await?;
391
392        if rows.is_empty() || rows.len() != ys.len() {
393            return Err(database::Error::ProofNotFound);
394        }
395
396        let results: Vec<(Proof, State)> = rows
397            .into_iter()
398            .map(sql_row_to_proof_with_state)
399            .collect::<Result<Vec<_>, _>>()?;
400
401        let mut proofs = Vec::with_capacity(results.len());
402        let mut first_state: Option<State> = None;
403
404        for (proof, state) in results {
405            if let Some(first) = first_state {
406                if first != state {
407                    return Err(database::Error::Internal(
408                        "Proofs have inconsistent states".to_string(),
409                    ));
410                }
411            } else {
412                first_state = Some(state);
413            }
414
415            proofs.push(proof);
416        }
417
418        let state = first_state.unwrap_or(State::Unspent);
419        Ok(ProofsWithState::new(proofs, state).into())
420    }
421}
422
423#[async_trait]
424impl<RM> MintProofsDatabase for SQLMintDatabase<RM>
425where
426    RM: DatabasePool + 'static,
427{
428    type Err = Error;
429
430    async fn get_proofs_by_ys(&self, ys: &[PublicKey]) -> Result<Vec<Option<Proof>>, Self::Err> {
431        let conn = self
432            .pool
433            .get()
434            .await
435            .map_err(|e| Error::Database(Box::new(e)))?;
436        let mut proofs = query(
437            r#"
438            SELECT
439                amount,
440                keyset_id,
441                secret,
442                c,
443                witness,
444                y
445            FROM
446                proof
447            WHERE
448                y IN (:ys)
449            "#,
450        )?
451        .bind_vec("ys", ys.iter().map(|y| y.to_bytes().to_vec()).collect())?
452        .fetch_all(&*conn)
453        .await?
454        .into_iter()
455        .map(|mut row| {
456            Ok((
457                column_as_string!(
458                    row.pop().ok_or(Error::InvalidDbResponse)?,
459                    PublicKey::from_hex,
460                    PublicKey::from_slice
461                ),
462                sql_row_to_proof(row)?,
463            ))
464        })
465        .collect::<Result<HashMap<_, _>, Error>>()?;
466
467        Ok(ys.iter().map(|y| proofs.remove(y)).collect())
468    }
469
470    async fn get_proof_ys_by_quote_id(
471        &self,
472        quote_id: &QuoteId,
473    ) -> Result<Vec<PublicKey>, Self::Err> {
474        let conn = self
475            .pool
476            .get()
477            .await
478            .map_err(|e| Error::Database(Box::new(e)))?;
479        Ok(query(
480            r#"
481            SELECT
482                amount,
483                keyset_id,
484                secret,
485                c,
486                witness
487            FROM
488                proof
489            WHERE
490                quote_id = :quote_id
491            "#,
492        )?
493        .bind("quote_id", quote_id.to_string())
494        .fetch_all(&*conn)
495        .await?
496        .into_iter()
497        .map(sql_row_to_proof)
498        .collect::<Result<Vec<Proof>, _>>()?
499        .ys()?)
500    }
501
502    async fn get_proofs_states(&self, ys: &[PublicKey]) -> Result<Vec<Option<State>>, Self::Err> {
503        let conn = self
504            .pool
505            .get()
506            .await
507            .map_err(|e| Error::Database(Box::new(e)))?;
508        let mut current_states = get_current_states(&*conn, ys, false).await?;
509
510        Ok(ys.iter().map(|y| current_states.remove(y)).collect())
511    }
512
513    async fn get_proofs_by_keyset_id(
514        &self,
515        keyset_id: &Id,
516    ) -> Result<(Proofs, Vec<Option<State>>), Self::Err> {
517        let conn = self
518            .pool
519            .get()
520            .await
521            .map_err(|e| Error::Database(Box::new(e)))?;
522
523        let (proofs, states): (Vec<Proof>, Vec<State>) = query(
524            r#"
525            SELECT
526               keyset_id,
527               amount,
528               secret,
529               c,
530               witness,
531               state
532            FROM
533                proof
534            WHERE
535                keyset_id=:keyset_id
536            "#,
537        )?
538        .bind("keyset_id", keyset_id.to_string())
539        .fetch_all(&*conn)
540        .await?
541        .into_iter()
542        .map(sql_row_to_proof_with_state)
543        .collect::<Result<Vec<_>, _>>()?
544        .into_iter()
545        .unzip();
546
547        Ok((proofs, states.into_iter().map(Some).collect()))
548    }
549
550    /// Get total proofs redeemed by keyset id
551    async fn get_total_redeemed(&self) -> Result<HashMap<Id, Amount>, Self::Err> {
552        let conn = self
553            .pool
554            .get()
555            .await
556            .map_err(|e| Error::Database(Box::new(e)))?;
557        query(
558            r#"
559            SELECT
560                keyset_id,
561                total_redeemed as amount
562            FROM
563                keyset_amounts
564        "#,
565        )?
566        .fetch_all(&*conn)
567        .await?
568        .into_iter()
569        .map(sql_row_to_hashmap_amount)
570        .collect()
571    }
572
573    async fn get_proof_ys_by_operation_id(
574        &self,
575        operation_id: &uuid::Uuid,
576    ) -> Result<Vec<PublicKey>, Self::Err> {
577        let conn = self
578            .pool
579            .get()
580            .await
581            .map_err(|e| Error::Database(Box::new(e)))?;
582        query(
583            r#"
584            SELECT
585                y
586            FROM
587                proof
588            WHERE
589                operation_id = :operation_id
590            "#,
591        )?
592        .bind("operation_id", operation_id.to_string())
593        .fetch_all(&*conn)
594        .await?
595        .into_iter()
596        .map(|row| -> Result<PublicKey, Error> {
597            Ok(column_as_string!(
598                &row[0],
599                PublicKey::from_hex,
600                PublicKey::from_slice
601            ))
602        })
603        .collect::<Result<Vec<_>, _>>()
604    }
605}