Skip to main content

cdk_sql_common/mint/
keys.rs

1//! Keys database implementation
2
3use std::collections::HashMap;
4use std::str::FromStr;
5
6use async_trait::async_trait;
7use bitcoin::bip32::DerivationPath;
8use cdk_common::common::IssuerVersion;
9use cdk_common::database::{Error, MintKeyDatabaseTransaction, MintKeysDatabase};
10use cdk_common::mint::MintKeySetInfo;
11use cdk_common::{CurrencyUnit, Id};
12
13use super::{SQLMintDatabase, SQLTransaction};
14use crate::database::{ConnectionWithTransaction, DatabaseExecutor};
15use crate::pool::DatabasePool;
16use crate::stmt::{query, Column};
17use crate::{
18    column_as_nullable_number, column_as_nullable_string, column_as_number, column_as_string,
19    unpack_into,
20};
21
22pub(crate) fn sql_row_to_keyset_info(row: Vec<Column>) -> Result<MintKeySetInfo, Error> {
23    unpack_into!(
24        let (
25            id,
26            unit,
27            active,
28            valid_from,
29            valid_to,
30            derivation_path,
31            derivation_path_index,
32            amounts,
33            row_keyset_ppk,
34            issuer_version
35        ) = row
36    );
37
38    let amounts = column_as_nullable_string!(amounts)
39        .and_then(|str| serde_json::from_str(&str).ok())
40        .ok_or_else(|| Error::Database("amounts field is required".to_string().into()))?;
41
42    Ok(MintKeySetInfo {
43        id: column_as_string!(id, Id::from_str, Id::from_bytes),
44        unit: column_as_string!(unit, CurrencyUnit::from_str),
45        active: matches!(active, Column::Integer(1)),
46        valid_from: column_as_number!(valid_from),
47        derivation_path: column_as_string!(derivation_path, DerivationPath::from_str),
48        derivation_path_index: column_as_nullable_number!(derivation_path_index),
49        amounts,
50        input_fee_ppk: column_as_nullable_number!(row_keyset_ppk).unwrap_or(0),
51        final_expiry: column_as_nullable_number!(valid_to),
52        issuer_version: column_as_nullable_string!(issuer_version).and_then(|v| {
53            match IssuerVersion::from_str(&v) {
54                Ok(ver) => Some(ver),
55                Err(e) => {
56                    tracing::warn!(
57                        "Failed to parse issuer_version from database: {}. Error: {}",
58                        v,
59                        e
60                    );
61                    None
62                }
63            }
64        }),
65    })
66}
67
68/// The keyset-info columns, in the order [`sql_row_to_keyset_info`] expects.
69const KEYSET_INFO_COLUMNS: &str = r#"
70    id,
71    unit,
72    active,
73    valid_from,
74    valid_to,
75    derivation_path,
76    derivation_path_index,
77    amounts,
78    input_fee_ppk,
79    issuer_version
80"#;
81
82/// Read the active keyset pointer for each unit, over any executor.
83///
84/// Shared by the autocommit [`MintKeysDatabase`] read and the transaction-scoped
85/// [`MintKeyDatabaseTransaction`] read so the SQL lives in one place.
86async fn read_active_keysets<C>(conn: &C) -> Result<HashMap<CurrencyUnit, Id>, Error>
87where
88    C: DatabaseExecutor,
89{
90    query(r#"SELECT id, unit FROM keyset WHERE active = :active"#)?
91        .bind("active", true)
92        .fetch_all(conn)
93        .await?
94        .into_iter()
95        .map(|row| {
96            Ok((
97                column_as_string!(&row[1], CurrencyUnit::from_str),
98                column_as_string!(&row[0], Id::from_str, Id::from_bytes),
99            ))
100        })
101        .collect::<Result<HashMap<_, _>, Error>>()
102}
103
104/// Read every keyset info, over any executor. See [`read_active_keysets`].
105async fn read_keyset_infos<C>(conn: &C) -> Result<Vec<MintKeySetInfo>, Error>
106where
107    C: DatabaseExecutor,
108{
109    query(&format!("SELECT {KEYSET_INFO_COLUMNS} FROM keyset"))?
110        .fetch_all(conn)
111        .await?
112        .into_iter()
113        .map(sql_row_to_keyset_info)
114        .collect::<Result<Vec<_>, _>>()
115}
116
117/// Read the single-row keyset epoch counter, over any executor. See
118/// [`read_active_keysets`].
119async fn read_keysets_epoch<C>(conn: &C) -> Result<u64, Error>
120where
121    C: DatabaseExecutor,
122{
123    Ok(
124        match query(r#"SELECT epoch FROM keyset_epoch WHERE id = 0"#)?
125            .pluck(conn)
126            .await?
127        {
128            Some(column) => column_as_number!(column),
129            None => 0,
130        },
131    )
132}
133
134#[async_trait]
135impl<RM> MintKeyDatabaseTransaction<'_, Error> for SQLTransaction<RM>
136where
137    RM: DatabasePool + 'static,
138{
139    async fn add_keyset_info(&mut self, keyset: MintKeySetInfo) -> Result<(), Error> {
140        query(
141            r#"
142        INSERT INTO
143            keyset (
144                id, unit, active, valid_from, valid_to, derivation_path,
145                amounts, input_fee_ppk, derivation_path_index, issuer_version
146            )
147        VALUES (
148            :id, :unit, :active, :valid_from, :valid_to, :derivation_path,
149            :amounts, :input_fee_ppk, :derivation_path_index, :issuer_version
150        )
151        ON CONFLICT(id) DO UPDATE SET
152            unit = excluded.unit,
153            active = excluded.active,
154            valid_from = excluded.valid_from,
155            valid_to = excluded.valid_to,
156            derivation_path = excluded.derivation_path,
157            amounts = excluded.amounts,
158            input_fee_ppk = excluded.input_fee_ppk,
159            derivation_path_index = excluded.derivation_path_index,
160            issuer_version = excluded.issuer_version
161        "#,
162        )?
163        .bind("id", keyset.id.to_string())
164        .bind("unit", keyset.unit.to_string())
165        .bind("active", keyset.active)
166        .bind("valid_from", keyset.valid_from as i64)
167        .bind("valid_to", keyset.final_expiry.map(|v| v as i64))
168        .bind("derivation_path", keyset.derivation_path.to_string())
169        .bind("amounts", serde_json::to_string(&keyset.amounts).ok())
170        .bind("input_fee_ppk", keyset.input_fee_ppk as i64)
171        .bind("derivation_path_index", keyset.derivation_path_index)
172        .bind(
173            "issuer_version",
174            keyset.issuer_version.map(|v| v.to_string()),
175        )
176        .execute(&self.inner)
177        .await?;
178
179        self.bump_keyset_epoch().await?;
180
181        Ok(())
182    }
183
184    async fn set_active_keyset(&mut self, unit: CurrencyUnit, id: Id) -> Result<(), Error> {
185        query(r#"UPDATE keyset SET active=FALSE WHERE unit = :unit"#)?
186            .bind("unit", unit.to_string())
187            .execute(&self.inner)
188            .await?;
189
190        query(r#"UPDATE keyset SET active=TRUE WHERE unit = :unit AND id = :id"#)?
191            .bind("unit", unit.to_string())
192            .bind("id", id.to_string())
193            .execute(&self.inner)
194            .await?;
195
196        self.bump_keyset_epoch().await?;
197
198        Ok(())
199    }
200
201    async fn next_derivation_index(&mut self, unit: &CurrencyUnit) -> Result<u32, Error> {
202        // No lock here: the transaction already holds the global keyset advisory
203        // lock (taken in `begin_transaction`), so all keyset transactions
204        // serialize and two rotations cannot read the same MAX index below.
205        let next = match query(
206            r#"SELECT COALESCE(MAX(derivation_path_index), 0) + 1 FROM keyset WHERE unit = :unit"#,
207        )?
208        .bind("unit", unit.to_string())
209        .pluck(&self.inner)
210        .await?
211        {
212            Some(column) => column_as_number!(column),
213            None => 1,
214        };
215
216        Ok(next)
217    }
218
219    async fn get_keyset_infos_by_unit(
220        &mut self,
221        unit: &CurrencyUnit,
222    ) -> Result<Vec<MintKeySetInfo>, Error> {
223        // No lock here: the transaction already holds the global keyset advisory
224        // lock, so a concurrent rotation cannot slip a higher keyset in between
225        // this read and the caller's active-pointer reassignment.
226        Ok(query(
227            r#"SELECT
228                id,
229                unit,
230                active,
231                valid_from,
232                valid_to,
233                derivation_path,
234                derivation_path_index,
235                amounts,
236                input_fee_ppk,
237                issuer_version
238            FROM
239                keyset
240                WHERE unit = :unit"#,
241        )?
242        .bind("unit", unit.to_string())
243        .fetch_all(&self.inner)
244        .await?
245        .into_iter()
246        .map(sql_row_to_keyset_info)
247        .collect::<Result<Vec<_>, _>>()?)
248    }
249
250    async fn get_active_keysets(&mut self) -> Result<HashMap<CurrencyUnit, Id>, Error> {
251        read_active_keysets(&self.inner).await
252    }
253
254    async fn get_keyset_infos(&mut self) -> Result<Vec<MintKeySetInfo>, Error> {
255        read_keyset_infos(&self.inner).await
256    }
257
258    async fn keysets_epoch(&mut self) -> Result<u64, Error> {
259        read_keysets_epoch(&self.inner).await
260    }
261}
262
263impl<RM> SQLTransaction<RM>
264where
265    RM: DatabasePool + 'static,
266{
267    /// Take the global keyset advisory lock, held until the transaction commits,
268    /// so every keyset transaction (rotation, reload, boot reactivation)
269    /// serializes across processes. This removes torn reads and index races
270    /// without per-unit lock bookkeeping.
271    ///
272    /// No-op on backends that already serialize writers (SQLite's
273    /// `BEGIN IMMEDIATE`). Postgres runs at `START TRANSACTION` isolation, which
274    /// does not serialize concurrent reads, so it takes an explicit,
275    /// non-standard lock. Dispatched by driver name, the same way migrations
276    /// are.
277    async fn lock_keysets(&self) -> Result<(), Error> {
278        if RM::Connection::name() == "postgres" {
279            query(r#"SELECT pg_advisory_xact_lock(hashtext('cdk:keysets'))"#)?
280                .execute(&self.inner)
281                .await?;
282        }
283
284        Ok(())
285    }
286
287    /// Bump the persisted keyset epoch so any keyset change (insert or
288    /// active-pointer reassignment) is observable by peers, which reload when
289    /// the epoch they loaded no longer matches.
290    ///
291    /// Upsert rather than a bare `UPDATE`: a plain update would silently affect
292    /// zero rows if row 0 were ever absent, freezing the epoch at its fallback
293    /// and stalling every peer's reload. The insert path makes the row
294    /// self-healing.
295    async fn bump_keyset_epoch(&self) -> Result<(), Error> {
296        // Qualify the existing value with the table name: on Postgres a bare
297        // `epoch` in the update expression is ambiguous between the target row
298        // and `excluded`. Matches the upsert style used elsewhere in this crate.
299        query(
300            r#"
301            INSERT INTO keyset_epoch (id, epoch) VALUES (0, 1)
302            ON CONFLICT (id) DO UPDATE SET epoch = keyset_epoch.epoch + 1
303            "#,
304        )?
305        .execute(&self.inner)
306        .await?;
307
308        Ok(())
309    }
310}
311
312#[async_trait]
313impl<RM> MintKeysDatabase for SQLMintDatabase<RM>
314where
315    RM: DatabasePool + 'static,
316{
317    type Err = Error;
318
319    async fn begin_transaction<'a>(
320        &'a self,
321    ) -> Result<Box<dyn MintKeyDatabaseTransaction<'a, Error> + Send + Sync + 'a>, Error> {
322        let tx = SQLTransaction {
323            inner: ConnectionWithTransaction::new(
324                self.pool
325                    .get()
326                    .await
327                    .map_err(|e| Error::Database(Box::new(e)))?,
328            )
329            .await?,
330        };
331
332        // Serialize every keyset transaction on one global advisory lock, held
333        // to commit. All keyset reads and writes then see a consistent snapshot
334        // without per-unit locking or torn-read retries.
335        tx.lock_keysets().await?;
336
337        Ok(Box::new(tx))
338    }
339
340    async fn keysets_epoch(&self) -> Result<u64, Self::Err> {
341        // A single-row counter bumped inside every keyset-writing transaction,
342        // so it moves on any change (insert or active-pointer reassignment). One
343        // row to read, far cheaper than reading every keyset.
344        let conn = self
345            .pool
346            .get()
347            .await
348            .map_err(|e| Error::Database(Box::new(e)))?;
349        read_keysets_epoch(&*conn).await
350    }
351}
352
353#[cfg(test)]
354mod test {
355    use super::*;
356
357    mod keyset_amounts_tests {
358        use super::*;
359
360        #[test]
361        fn keyset_with_amounts() {
362            let amounts = (0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>();
363            let result = sql_row_to_keyset_info(vec![
364                Column::Text("0083a60439303340".to_owned()),
365                Column::Text("sat".to_owned()),
366                Column::Integer(1),
367                Column::Integer(1749844864),
368                Column::Null,
369                Column::Text("0'/0'/0'".to_owned()),
370                Column::Integer(0),
371                Column::Text(serde_json::to_string(&amounts).expect("valid json")),
372                Column::Integer(0),
373                Column::Text("cdk/0.1.0".to_owned()),
374            ]);
375            assert!(result.is_ok());
376            let keyset = result.unwrap();
377            assert_eq!(keyset.amounts.len(), 32);
378            assert_eq!(
379                keyset.issuer_version,
380                Some(IssuerVersion::from_str("cdk/0.1.0").unwrap())
381            );
382        }
383    }
384}