feat: add keyset u32 mapping migration (#926)

* feat: add keyset u32 mapping migration and duplicate handling

- Add new database migration (version 3) to include u32 representation for keysets
- Implement migration for both redb and SQL databases
- Add duplicate detection and handling for keyset entries
- Create unique index constraint for keyset_u32 column in SQL
- Update keyset storage to include u32 identifiers
- Handle backwards compatibility for existing databases

* chore: clippy

* refactor(cashu): simplify keyset ID verification logic

- Consolidate match expression into a single expression
- Use direct comparison with ensure_cdk macro
- Improve readability of keyset ID validation

* refactor(cdk): rename `fetch_keyset_keys` to `load_keyset_keys` for clarity

- Renamed `fetch_keyset_keys` to `load_keyset_keys` across multiple modules to better reflect its behavior of loading keys from local storage or fetching from mint when missing.
- Added debug logging to indicate when keys are being fetched from the mint.
- Simplified key loading logic in `update_mint_keysets` by removing redundant existence checks.

* chore: remove unused vec
This commit is contained in:
thesimplekid
2025-07-31 10:04:38 -04:00
committed by GitHub
parent 3a3cd88ee9
commit 3c4fce5c45
12 changed files with 266 additions and 42 deletions

View File

@@ -445,18 +445,17 @@ pub struct KeySet {
impl KeySet {
/// Verify the keyset id matches keys
pub fn verify_id(&self) -> Result<(), Error> {
match self.id.version {
KeySetVersion::Version00 => {
let keys_id: Id = Id::v1_from_keys(&self.keys);
let keys_id = match self.id.version {
KeySetVersion::Version00 => Id::v1_from_keys(&self.keys),
KeySetVersion::Version01 => Id::v2_from_data(&self.keys, &self.unit, self.final_expiry),
};
ensure_cdk!(keys_id == self.id, Error::IncorrectKeysetId);
}
KeySetVersion::Version01 => {
let keys_id: Id = Id::v2_from_data(&self.keys, &self.unit, self.final_expiry);
ensure_cdk!(
u32::from(keys_id) == u32::from(self.id),
Error::IncorrectKeysetId
);
ensure_cdk!(keys_id == self.id, Error::IncorrectKeysetId);
}
}
ensure_cdk!(keys_id == self.id, Error::IncorrectKeysetId);
Ok(())
}

View File

@@ -64,6 +64,9 @@ pub enum Error {
/// Unknown Database Version
#[error("Unknown database version")]
UnknownDatabaseVersion,
/// Duplicate
#[error("Duplicate")]
Duplicate,
}
impl From<Error> for cdk_common::database::Error {

View File

@@ -1,14 +1,17 @@
//! Wallet Migrations
use std::collections::HashSet;
use std::ops::Deref;
use std::str::FromStr;
use std::sync::Arc;
use cdk_common::mint_url::MintUrl;
use cdk_common::Id;
use redb::{
Database, MultimapTableDefinition, ReadableMultimapTable, ReadableTable, TableDefinition,
};
use super::Error;
use crate::wallet::{KEYSETS_TABLE, KEYSET_U32_MAPPING, MINT_KEYS_TABLE};
// <Mint_url, Info>
const MINTS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("mints_table");
@@ -16,6 +19,57 @@ const MINTS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("mints_tab
const MINT_KEYSETS_TABLE: MultimapTableDefinition<&str, &[u8]> =
MultimapTableDefinition::new("mint_keysets");
pub(crate) fn migrate_02_to_03(db: Arc<Database>) -> Result<u32, Error> {
let write_txn = db.begin_write().map_err(Error::from)?;
let mut duplicate = false;
{
let table = write_txn.open_table(MINT_KEYS_TABLE).map_err(Error::from)?;
let ids: Vec<Id> = table
.iter()
.map_err(Error::from)?
.flatten()
.flat_map(|(id, _)| Id::from_str(id.value()))
.collect();
let mut table = write_txn
.open_table(KEYSET_U32_MAPPING)
.map_err(Error::from)?;
// Also process existing keysets
let keysets_table = write_txn.open_table(KEYSETS_TABLE).map_err(Error::from)?;
let keyset_ids: Vec<Id> = keysets_table
.iter()
.map_err(Error::from)?
.flatten()
.flat_map(|(id_bytes, _)| Id::from_bytes(id_bytes.value()))
.collect();
let ids: HashSet<Id> = ids.into_iter().chain(keyset_ids).collect();
for id in ids {
let t = table.insert(u32::from(id), id.to_string().as_str())?;
tracing::info!("Adding u32 {} for keyset {}", u32::from(id), id.to_string());
if t.is_some() {
duplicate = true;
}
}
}
if duplicate {
write_txn.abort()?;
return Err(Error::Duplicate);
}
write_txn.commit()?;
Ok(3)
}
pub fn migrate_01_to_02(db: Arc<Database>) -> Result<u32, Error> {
migrate_trim_mint_urls_01_to_02(db)?;
Ok(2)

View File

@@ -21,7 +21,7 @@ use tracing::instrument;
use super::error::Error;
use crate::migrations::migrate_00_to_01;
use crate::wallet::migrations::migrate_01_to_02;
use crate::wallet::migrations::{migrate_01_to_02, migrate_02_to_03};
mod migrations;
@@ -44,7 +44,9 @@ const KEYSET_COUNTER: TableDefinition<&str, u32> = TableDefinition::new("keyset_
// <Transaction_id, Transaction>
const TRANSACTIONS_TABLE: TableDefinition<&[u8], &str> = TableDefinition::new("transactions");
const DATABASE_VERSION: u32 = 2;
const KEYSET_U32_MAPPING: TableDefinition<u32, &str> = TableDefinition::new("keyset_u32_mapping");
const DATABASE_VERSION: u32 = 3;
/// Wallet Redb Database
#[derive(Debug, Clone)]
@@ -90,6 +92,10 @@ impl WalletRedbDatabase {
current_file_version = migrate_01_to_02(Arc::clone(&db))?;
}
if current_file_version == 2 {
current_file_version = migrate_02_to_03(Arc::clone(&db))?;
}
if current_file_version != DATABASE_VERSION {
tracing::warn!(
"Database upgrade did not complete at {} current is {}",
@@ -136,6 +142,7 @@ impl WalletRedbDatabase {
let _ = write_txn.open_table(PROOFS_TABLE)?;
let _ = write_txn.open_table(KEYSET_COUNTER)?;
let _ = write_txn.open_table(TRANSACTIONS_TABLE)?;
let _ = write_txn.open_table(KEYSET_U32_MAPPING)?;
table.insert("db_version", DATABASE_VERSION.to_string().as_str())?;
}
@@ -290,20 +297,64 @@ impl WalletDatabase for WalletRedbDatabase {
) -> Result<(), Self::Err> {
let write_txn = self.db.begin_write().map_err(Error::from)?;
let mut existing_u32 = false;
{
let mut table = write_txn
.open_multimap_table(MINT_KEYSETS_TABLE)
.map_err(Error::from)?;
let mut keysets_table = write_txn.open_table(KEYSETS_TABLE).map_err(Error::from)?;
let mut u32_table = write_txn
.open_table(KEYSET_U32_MAPPING)
.map_err(Error::from)?;
for keyset in keysets {
table
.insert(
mint_url.to_string().as_str(),
keyset.id.to_bytes().as_slice(),
)
// Check if keyset already exists
let existing_keyset = {
let existing_keyset = keysets_table
.get(keyset.id.to_bytes().as_slice())
.map_err(Error::from)?;
existing_keyset.map(|r| r.value().to_string())
};
let existing = u32_table
.insert(u32::from(keyset.id), keyset.id.to_string().as_str())
.map_err(Error::from)?;
match existing {
None => existing_u32 = false,
Some(id) => {
let id = Id::from_str(id.value())?;
if id == keyset.id {
existing_u32 = false;
} else {
println!("Breaking here");
existing_u32 = true;
break;
}
}
}
let keyset = if let Some(existing_keyset) = existing_keyset {
let mut existing_keyset: KeySetInfo = serde_json::from_str(&existing_keyset)?;
existing_keyset.active = keyset.active;
existing_keyset.input_fee_ppk = keyset.input_fee_ppk;
existing_keyset
} else {
table
.insert(
mint_url.to_string().as_str(),
keyset.id.to_bytes().as_slice(),
)
.map_err(Error::from)?;
keyset
};
keysets_table
.insert(
keyset.id.to_bytes().as_slice(),
@@ -314,6 +365,14 @@ impl WalletDatabase for WalletRedbDatabase {
.map_err(Error::from)?;
}
}
if existing_u32 {
tracing::warn!("Keyset already exists for keyset id");
write_txn.abort().map_err(Error::from)?;
return Err(database::Error::Duplicate);
}
write_txn.commit().map_err(Error::from)?;
Ok(())
@@ -514,16 +573,45 @@ impl WalletDatabase for WalletRedbDatabase {
keyset.verify_id()?;
let existing_keys;
let existing_u32;
{
let mut table = write_txn.open_table(MINT_KEYS_TABLE).map_err(Error::from)?;
table
existing_keys = table
.insert(
keyset.id.to_string().as_str(),
serde_json::to_string(&keyset.keys)
.map_err(Error::from)?
.as_str(),
)
.map_err(Error::from)?
.is_some();
let mut table = write_txn
.open_table(KEYSET_U32_MAPPING)
.map_err(Error::from)?;
let existing = table
.insert(u32::from(keyset.id), keyset.id.to_string().as_str())
.map_err(Error::from)?;
match existing {
None => existing_u32 = false,
Some(id) => {
let id = Id::from_str(id.value())?;
existing_u32 = id != keyset.id;
}
}
}
if existing_keys || existing_u32 {
tracing::warn!("Keys already exist for keyset id");
write_txn.abort().map_err(Error::from)?;
return Err(database::Error::Duplicate);
}
write_txn.commit().map_err(Error::from)?;

View File

@@ -19,4 +19,5 @@ pub static MIGRATIONS: &[(&str, &str, &str)] = &[
("sqlite", "20250401120000_add_transactions_table.sql", include_str!(r#"./migrations/sqlite/20250401120000_add_transactions_table.sql"#)),
("sqlite", "20250616144830_add_keyset_expiry.sql", include_str!(r#"./migrations/sqlite/20250616144830_add_keyset_expiry.sql"#)),
("sqlite", "20250707093445_bolt12.sql", include_str!(r#"./migrations/sqlite/20250707093445_bolt12.sql"#)),
("sqlite", "20250729111701_keyset_v2_u32.sql", include_str!(r#"./migrations/sqlite/20250729111701_keyset_v2_u32.sql"#)),
];

View File

@@ -0,0 +1,11 @@
-- Add u32 representation column to key table with unique constraint
ALTER TABLE key ADD COLUMN keyset_u32 INTEGER;
-- Add unique constraint on the new column
CREATE UNIQUE INDEX IF NOT EXISTS keyset_u32_unique ON key(keyset_u32);
-- Add u32 representation column to keyset table with unique constraint
ALTER TABLE keyset ADD COLUMN keyset_u32 INTEGER;
-- Add unique constraint on the new column
CREATE UNIQUE INDEX IF NOT EXISTS keyset_u32_unique_keyset ON keyset(keyset_u32);

View File

@@ -53,6 +53,74 @@ where
/// Migrate [`WalletSqliteDatabase`]
async fn migrate(conn: &DB) -> Result<(), Error> {
migrate(conn, DB::name(), migrations::MIGRATIONS).await?;
// Update any existing keys with missing keyset_u32 values
Self::add_keyset_u32(conn).await?;
Ok(())
}
async fn add_keyset_u32(conn: &DB) -> Result<(), Error> {
// First get the keysets where keyset_u32 on key is null
let keys_without_u32: Vec<Vec<Column>> = query(
r#"
SELECT
id
FROM key
WHERE keyset_u32 IS NULL
"#,
)?
.fetch_all(conn)
.await?;
for id in keys_without_u32 {
let id = column_as_string!(id.first().unwrap());
if let Ok(id) = Id::from_str(&id) {
query(
r#"
UPDATE
key
SET keyset_u32 = :u32_keyset
WHERE id = :keyset_id
"#,
)?
.bind("u32_keyset", u32::from(id))
.bind("keyset_id", id.to_string())
.execute(conn)
.await?;
}
}
// Also update keysets where keyset_u32 is null
let keysets_without_u32: Vec<Vec<Column>> = query(
r#"
SELECT
id
FROM keyset
WHERE keyset_u32 IS NULL
"#,
)?
.fetch_all(conn)
.await?;
for id in keysets_without_u32 {
let id = column_as_string!(id.first().unwrap());
if let Ok(id) = Id::from_str(&id) {
query(
r#"
UPDATE
keyset
SET keyset_u32 = :u32_keyset
WHERE id = :keyset_id
"#,
)?
.bind("u32_keyset", u32::from(id))
.bind("keyset_id", id.to_string())
.execute(conn)
.await?;
}
}
Ok(())
}
}
@@ -301,15 +369,12 @@ ON CONFLICT(mint_url) DO UPDATE SET
query(
r#"
INSERT INTO keyset
(mint_url, id, unit, active, input_fee_ppk, final_expiry)
(mint_url, id, unit, active, input_fee_ppk, final_expiry, keyset_u32)
VALUES
(:mint_url, :id, :unit, :active, :input_fee_ppk, :final_expiry)
(:mint_url, :id, :unit, :active, :input_fee_ppk, :final_expiry, :keyset_u32)
ON CONFLICT(id) DO UPDATE SET
mint_url = excluded.mint_url,
unit = excluded.unit,
active = excluded.active,
input_fee_ppk = excluded.input_fee_ppk,
final_expiry = excluded.final_expiry;
input_fee_ppk = excluded.input_fee_ppk
"#,
)?
.bind("mint_url", mint_url.to_string())
@@ -318,6 +383,7 @@ ON CONFLICT(mint_url) DO UPDATE SET
.bind("active", keyset.active)
.bind("input_fee_ppk", keyset.input_fee_ppk as i64)
.bind("final_expiry", keyset.final_expiry.map(|v| v as i64))
.bind("keyset_u32", u32::from(keyset.id))
.execute(&self.db)
.await?;
}
@@ -554,11 +620,9 @@ ON CONFLICT(id) DO UPDATE SET
query(
r#"
INSERT INTO key
(id, keys)
(id, keys, keyset_u32)
VALUES
(:id, :keys)
ON CONFLICT(id) DO UPDATE SET
keys = excluded.keys
(:id, :keys, :keyset_u32)
"#,
)?
.bind("id", keyset.id.to_string())
@@ -566,6 +630,7 @@ ON CONFLICT(id) DO UPDATE SET
"keys",
serde_json::to_string(&keyset.keys).map_err(Error::from)?,
)
.bind("keyset_u32", u32::from(keyset.id))
.execute(&self.db)
.await?;

View File

@@ -264,12 +264,12 @@ impl Wallet {
let mint_res = self.client.post_mint(request).await?;
let keys = self.fetch_keyset_keys(active_keyset_id).await?;
let keys = self.load_keyset_keys(active_keyset_id).await?;
// Verify the signature DLEQ is valid
{
for (sig, premint) in mint_res.signatures.iter().zip(&premint_secrets.secrets) {
let keys = self.fetch_keyset_keys(sig.keyset_id).await?;
let keys = self.load_keyset_keys(sig.keyset_id).await?;
let key = keys.amount_key(sig.amount).ok_or(Error::AmountKey)?;
match sig.verify_dleq(key, premint.blinded_message.blinded_secret) {
Ok(_) | Err(nut12::Error::MissingDleqProof) => (),

View File

@@ -161,12 +161,12 @@ impl Wallet {
let mint_res = self.client.post_mint(request).await?;
let keys = self.fetch_keyset_keys(active_keyset_id).await?;
let keys = self.load_keyset_keys(active_keyset_id).await?;
// Verify the signature DLEQ is valid
{
for (sig, premint) in mint_res.signatures.iter().zip(&premint_secrets.secrets) {
let keys = self.fetch_keyset_keys(sig.keyset_id).await?;
let keys = self.load_keyset_keys(sig.keyset_id).await?;
let key = keys.amount_key(sig.amount).ok_or(Error::AmountKey)?;
match sig.verify_dleq(key, premint.blinded_message.blinded_secret) {
Ok(_) | Err(nut12::Error::MissingDleqProof) => (),

View File

@@ -7,15 +7,21 @@ use crate::nuts::{Id, KeySetInfo, Keys};
use crate::{Error, Wallet};
impl Wallet {
/// Fetch keys for mint keyset
/// Load keys for mint keyset
///
/// Returns keys from local database if they are already stored.
/// If keys are not found locally, goes online to query the mint for the keyset and stores the [`Keys`] in local database.
#[instrument(skip(self))]
pub async fn fetch_keyset_keys(&self, keyset_id: Id) -> Result<Keys, Error> {
pub async fn load_keyset_keys(&self, keyset_id: Id) -> Result<Keys, Error> {
let keys = if let Some(keys) = self.localstore.get_keys(&keyset_id).await? {
keys
} else {
tracing::debug!(
"Keyset {} not in db fetching from mint {}",
keyset_id,
self.mint_url
);
let keys = self.client.get_mint_keyset(keyset_id).await?;
keys.verify_id()?;
@@ -92,10 +98,7 @@ impl Wallet {
// Ensure we have keys for all active keysets
for keyset in &keysets {
if self.localstore.get_keys(&keyset.id).await?.is_none() {
tracing::debug!("Fetching missing keys for keyset {}", keyset.id);
self.fetch_keyset_keys(keyset.id).await?;
}
self.load_keyset_keys(keyset.id).await?;
}
Ok(keysets)

View File

@@ -383,7 +383,7 @@ impl Wallet {
let mut restored_value = Amount::ZERO;
for keyset in keysets {
let keys = self.fetch_keyset_keys(keyset.id).await?;
let keys = self.load_keyset_keys(keyset.id).await?;
let mut empty_batch = 0;
let mut start_counter = 0;
@@ -632,7 +632,7 @@ impl Wallet {
let mint_pubkey = match keys_cache.get(&proof.keyset_id) {
Some(keys) => keys.amount_key(proof.amount),
None => {
let keys = self.fetch_keyset_keys(proof.keyset_id).await?;
let keys = self.load_keyset_keys(proof.keyset_id).await?;
let key = keys.amount_key(proof.amount);
keys_cache.insert(proof.keyset_id, keys);

View File

@@ -42,7 +42,7 @@ impl Wallet {
let active_keyset_id = self.fetch_active_keyset().await?.id;
let keys = self.fetch_keyset_keys(active_keyset_id).await?;
let keys = self.load_keyset_keys(active_keyset_id).await?;
let mut proofs = proofs;
@@ -70,7 +70,7 @@ impl Wallet {
for proof in &mut proofs {
// Verify that proof DLEQ is valid
if proof.dleq.is_some() {
let keys = self.fetch_keyset_keys(proof.keyset_id).await?;
let keys = self.load_keyset_keys(proof.keyset_id).await?;
let key = keys.amount_key(proof.amount).ok_or(Error::AmountKey)?;
proof.verify_dleq(key)?;
}