Skip to content

Commit 4b04d10

Browse files
authored
Check change unique (#1112)
* fix(cdk): prevent duplicate blinded message processing with database constraints Add unique constraints on blinded_message column in both PostgreSQL and SQLite databases, and implement application-level checks to prevent duplicate blinded messages from being processed. Also ensure proper cleanup of melt requests after successful processing. * feat: db tests for unique * refactor(cdk-sql): consolidate blinded messages into blind signature table Migrate from separate blinded_messages table to unified blind_signature table. Add signed_time column and make c column nullable to track both pending blind messages (c=NULL) and completed signatures. Update insert/update logic to handle upsert scenarios for blind signature completion. * refactor(cdk-sql): remove unique constraint migration and filter queries for signed messages Remove database-level unique constraint on blinded_message and instead filter queries to only consider messages with signatures (c IS NOT NULL * refactor(database): improve blinded message duplicate detection using database constraints Replace manual duplicate checking with database constraint handling for better reliability and simplified code flow in melt request processing. * refactor(cdk-sql): optimize blind signature processing with batch queries Replace individual queries per blinded message with single batch query and HashMap lookup to eliminate N+1 query performance issue. * fix: signed time to swap sigs * refactor(cdk): split blinded message handling and improve duplicate detection - Split add_melt_request_and_blinded_messages into separate methods - Add blinded messages to database before signing in swap operations - Improve duplicate output detection with proper error handling - Make add_blinded_messages method accept optional quote_id for flexibility * refactor(cdk): add BlindedMessageWriter for improved transaction rollback - Add BlindedMessageWriter component for managing blinded message state - Implement proper rollback mechanisms in swap operations - Add delete_blinded_messages database interface for cleanup - Improve error handling with better state management
1 parent 6d0003a commit 4b04d10

11 files changed

Lines changed: 692 additions & 55 deletions

File tree

.typos.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@ extend-ignore-re = [
66
"casshuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9",
77
"autheticator",
88
"Gam",
9-
"flate2"
9+
"flate2",
10+
"lnbc[A-Za-z0-9-_]+"
1011
]

crates/cdk-common/src/database/mint/mod.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,15 +132,27 @@ pub trait QuotesTransaction<'a> {
132132
/// Mint Quotes Database Error
133133
type Err: Into<Error> + From<Error>;
134134

135-
/// Add melt_request with quote_id, inputs_amount, and blinded_messages
136-
async fn add_melt_request_and_blinded_messages(
135+
/// Add melt_request with quote_id, inputs_amount, and inputs_fee
136+
async fn add_melt_request(
137137
&mut self,
138138
quote_id: &QuoteId,
139139
inputs_amount: Amount,
140140
inputs_fee: Amount,
141+
) -> Result<(), Self::Err>;
142+
143+
/// Add blinded_messages for a quote_id
144+
async fn add_blinded_messages(
145+
&mut self,
146+
quote_id: Option<&QuoteId>,
141147
blinded_messages: &[BlindedMessage],
142148
) -> Result<(), Self::Err>;
143149

150+
/// Delete blinded_messages by their blinded secrets
151+
async fn delete_blinded_messages(
152+
&mut self,
153+
blinded_secrets: &[PublicKey],
154+
) -> Result<(), Self::Err>;
155+
144156
/// Get melt_request and associated blinded_messages by quote_id
145157
async fn get_melt_request_and_blinded_messages(
146158
&mut self,

crates/cdk-common/src/database/mint/test/mint.rs

Lines changed: 197 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
//! Payments
22
3+
use std::str::FromStr;
4+
5+
use cashu::quote_id::QuoteId;
6+
use cashu::{Amount, Id, SecretKey};
7+
38
use crate::database::mint::test::unique_string;
49
use crate::database::mint::{Database, Error, KeysDatabase};
5-
use crate::mint::MintQuote;
10+
use crate::database::MintSignaturesDatabase;
11+
use crate::mint::{MeltPaymentRequest, MeltQuote, MintQuote};
612
use crate::payment::PaymentIdentifier;
713

814
/// Add a mint quote
@@ -404,3 +410,193 @@ where
404410
.await
405411
.is_err());
406412
}
413+
/// Successful melt with unique blinded messages
414+
pub async fn add_melt_request_unique_blinded_messages<DB>(db: DB)
415+
where
416+
DB: Database<Error> + KeysDatabase<Err = Error> + MintSignaturesDatabase<Err = Error>,
417+
{
418+
let inputs_amount = Amount::from(100u64);
419+
let inputs_fee = Amount::from(1u64);
420+
let keyset_id = Id::from_str("001711afb1de20cb").unwrap();
421+
422+
// Create a dummy blinded message
423+
let blinded_secret = SecretKey::generate().public_key();
424+
let blinded_message = cashu::BlindedMessage {
425+
blinded_secret,
426+
keyset_id,
427+
amount: Amount::from(100u64),
428+
witness: None,
429+
};
430+
let blinded_messages = vec![blinded_message];
431+
432+
let mut tx = Database::begin_transaction(&db).await.unwrap();
433+
let quote = MeltQuote::new(MeltPaymentRequest::Bolt11 { bolt11: "lnbc330n1p5d85skpp5344v3ktclujsjl3h09wgsfm7zytumr7h7zhrl857f5w8nv0a52zqdqqcqzzsxqyz5vqrzjqvueefmrckfdwyyu39m0lf24sqzcr9vcrmxrvgfn6empxz7phrjxvrttncqq0lcqqyqqqqlgqqqqqqgq2qsp5j3rrg8kvpemqxtf86j8tjm90wq77c7ende4e5qmrerq4xsg02vhq9qxpqysgqjltywgyk6uc5qcgwh8xnzmawl2tjlhz8d28tgp3yx8xwtz76x0jqkfh6mmq70hervjxs0keun7ur0spldgll29l0dnz3md50d65sfqqqwrwpsu".parse().unwrap() }, cashu::CurrencyUnit::Sat, 33.into(), Amount::ZERO, 0, None, None, cashu::PaymentMethod::Bolt11);
434+
tx.add_melt_quote(quote.clone()).await.unwrap();
435+
tx.add_melt_request(&quote.id, inputs_amount, inputs_fee)
436+
.await
437+
.unwrap();
438+
tx.add_blinded_messages(Some(&quote.id), &blinded_messages)
439+
.await
440+
.unwrap();
441+
tx.commit().await.unwrap();
442+
443+
// Verify retrieval
444+
let mut tx = Database::begin_transaction(&db).await.unwrap();
445+
let retrieved = tx
446+
.get_melt_request_and_blinded_messages(&quote.id)
447+
.await
448+
.unwrap()
449+
.unwrap();
450+
assert_eq!(retrieved.inputs_amount, inputs_amount);
451+
assert_eq!(retrieved.inputs_fee, inputs_fee);
452+
assert_eq!(retrieved.change_outputs.len(), 1);
453+
assert_eq!(retrieved.change_outputs[0].amount, Amount::from(100u64));
454+
tx.commit().await.unwrap();
455+
}
456+
457+
/// Reject melt with duplicate blinded message (already signed)
458+
pub async fn reject_melt_duplicate_blinded_signature<DB>(db: DB)
459+
where
460+
DB: Database<Error> + KeysDatabase<Err = Error> + MintSignaturesDatabase<Err = Error>,
461+
{
462+
let quote_id1 = QuoteId::new_uuid();
463+
let inputs_amount = Amount::from(100u64);
464+
let inputs_fee = Amount::from(1u64);
465+
let keyset_id = Id::from_str("001711afb1de20cb").unwrap();
466+
467+
// Create a dummy blinded message
468+
let blinded_secret = SecretKey::generate().public_key();
469+
let blinded_message = cashu::BlindedMessage {
470+
blinded_secret,
471+
keyset_id,
472+
amount: Amount::from(100u64),
473+
witness: None,
474+
};
475+
let blinded_messages = vec![blinded_message.clone()];
476+
477+
// First, "sign" it by adding to blind_signature (simulate successful mint)
478+
let mut tx = Database::begin_transaction(&db).await.unwrap();
479+
let c = SecretKey::generate().public_key();
480+
let blind_sig = cashu::BlindSignature {
481+
amount: Amount::from(100u64),
482+
keyset_id,
483+
c,
484+
dleq: None,
485+
};
486+
let blinded_secrets = vec![blinded_message.blinded_secret];
487+
tx.add_blind_signatures(&blinded_secrets, &[blind_sig], Some(quote_id1))
488+
.await
489+
.unwrap();
490+
tx.commit().await.unwrap();
491+
492+
// Now try to add melt request with the same blinded message - should fail due to constraint
493+
let mut tx = Database::begin_transaction(&db).await.unwrap();
494+
let quote2 = MeltQuote::new(MeltPaymentRequest::Bolt11 { bolt11: "lnbc330n1p5d85skpp5344v3ktclujsjl3h09wgsfm7zytumr7h7zhrl857f5w8nv0a52zqdqqcqzzsxqyz5vqrzjqvueefmrckfdwyyu39m0lf24sqzcr9vcrmxrvgfn6empxz7phrjxvrttncqq0lcqqyqqqqlgqqqqqqgq2qsp5j3rrg8kvpemqxtf86j8tjm90wq77c7ende4e5qmrerq4xsg02vhq9qxpqysgqjltywgyk6uc5qcgwh8xnzmawl2tjlhz8d28tgp3yx8xwtz76x0jqkfh6mmq70hervjxs0keun7ur0spldgll29l0dnz3md50d65sfqqqwrwpsu".parse().unwrap() }, cashu::CurrencyUnit::Sat, 33.into(), Amount::ZERO, 0, None, None, cashu::PaymentMethod::Bolt11);
495+
tx.add_melt_quote(quote2.clone()).await.unwrap();
496+
tx.add_melt_request(&quote2.id, inputs_amount, inputs_fee)
497+
.await
498+
.unwrap();
499+
let result = tx
500+
.add_blinded_messages(Some(&quote2.id), &blinded_messages)
501+
.await;
502+
assert!(result.is_err() && matches!(result.unwrap_err(), Error::Duplicate));
503+
tx.rollback().await.unwrap(); // Rollback to avoid partial state
504+
}
505+
506+
/// Reject duplicate blinded message insert via DB constraint (different quotes)
507+
pub async fn reject_duplicate_blinded_message_db_constraint<DB>(db: DB)
508+
where
509+
DB: Database<Error> + KeysDatabase<Err = Error>,
510+
{
511+
let inputs_amount = Amount::from(100u64);
512+
let inputs_fee = Amount::from(1u64);
513+
let keyset_id = Id::from_str("001711afb1de20cb").unwrap();
514+
515+
// Create a dummy blinded message
516+
let blinded_secret = SecretKey::generate().public_key();
517+
let blinded_message = cashu::BlindedMessage {
518+
blinded_secret,
519+
keyset_id,
520+
amount: Amount::from(100u64),
521+
witness: None,
522+
};
523+
let blinded_messages = vec![blinded_message];
524+
525+
// First insert succeeds
526+
let mut tx = Database::begin_transaction(&db).await.unwrap();
527+
let quote = MeltQuote::new(MeltPaymentRequest::Bolt11 { bolt11: "lnbc330n1p5d85skpp5344v3ktclujsjl3h09wgsfm7zytumr7h7zhrl857f5w8nv0a52zqdqqcqzzsxqyz5vqrzjqvueefmrckfdwyyu39m0lf24sqzcr9vcrmxrvgfn6empxz7phrjxvrttncqq0lcqqyqqqqlgqqqqqqgq2qsp5j3rrg8kvpemqxtf86j8tjm90wq77c7ende4e5qmrerq4xsg02vhq9qxpqysgqjltywgyk6uc5qcgwh8xnzmawl2tjlhz8d28tgp3yx8xwtz76x0jqkfh6mmq70hervjxs0keun7ur0spldgll29l0dnz3md50d65sfqqqwrwpsu".parse().unwrap() }, cashu::CurrencyUnit::Sat, 33.into(), Amount::ZERO, 0, None, None, cashu::PaymentMethod::Bolt11);
528+
tx.add_melt_quote(quote.clone()).await.unwrap();
529+
tx.add_melt_request(&quote.id, inputs_amount, inputs_fee)
530+
.await
531+
.unwrap();
532+
assert!(tx
533+
.add_blinded_messages(Some(&quote.id), &blinded_messages)
534+
.await
535+
.is_ok());
536+
tx.commit().await.unwrap();
537+
538+
// Second insert with same blinded_message but different quote_id should fail due to unique constraint on blinded_message
539+
let mut tx = Database::begin_transaction(&db).await.unwrap();
540+
let quote = MeltQuote::new(MeltPaymentRequest::Bolt11 { bolt11: "lnbc330n1p5d85skpp5344v3ktclujsjl3h09wgsfm7zytumr7h7zhrl857f5w8nv0a52zqdqqcqzzsxqyz5vqrzjqvueefmrckfdwyyu39m0lf24sqzcr9vcrmxrvgfn6empxz7phrjxvrttncqq0lcqqyqqqqlgqqqqqqgq2qsp5j3rrg8kvpemqxtf86j8tjm90wq77c7ende4e5qmrerq4xsg02vhq9qxpqysgqjltywgyk6uc5qcgwh8xnzmawl2tjlhz8d28tgp3yx8xwtz76x0jqkfh6mmq70hervjxs0keun7ur0spldgll29l0dnz3md50d65sfqqqwrwpsu".parse().unwrap() }, cashu::CurrencyUnit::Sat, 33.into(), Amount::ZERO, 0, None, None, cashu::PaymentMethod::Bolt11);
541+
tx.add_melt_quote(quote.clone()).await.unwrap();
542+
tx.add_melt_request(&quote.id, inputs_amount, inputs_fee)
543+
.await
544+
.unwrap();
545+
let result = tx
546+
.add_blinded_messages(Some(&quote.id), &blinded_messages)
547+
.await;
548+
// Expect a database error due to unique violation
549+
assert!(result.is_err()); // Specific error might be DB-specific, e.g., SqliteError or PostgresError
550+
tx.rollback().await.unwrap();
551+
}
552+
553+
/// Cleanup of melt request after processing
554+
pub async fn cleanup_melt_request_after_processing<DB>(db: DB)
555+
where
556+
DB: Database<Error> + KeysDatabase<Err = Error>,
557+
{
558+
let inputs_amount = Amount::from(100u64);
559+
let inputs_fee = Amount::from(1u64);
560+
let keyset_id = Id::from_str("001711afb1de20cb").unwrap();
561+
562+
// Create dummy blinded message
563+
let blinded_secret = SecretKey::generate().public_key();
564+
let blinded_message = cashu::BlindedMessage {
565+
blinded_secret,
566+
keyset_id,
567+
amount: Amount::from(100u64),
568+
witness: None,
569+
};
570+
let blinded_messages = vec![blinded_message];
571+
572+
// Insert melt request
573+
let mut tx1 = Database::begin_transaction(&db).await.unwrap();
574+
let quote = MeltQuote::new(MeltPaymentRequest::Bolt11 { bolt11: "lnbc330n1p5d85skpp5344v3ktclujsjl3h09wgsfm7zytumr7h7zhrl857f5w8nv0a52zqdqqcqzzsxqyz5vqrzjqvueefmrckfdwyyu39m0lf24sqzcr9vcrmxrvgfn6empxz7phrjxvrttncqq0lcqqyqqqqlgqqqqqqgq2qsp5j3rrg8kvpemqxtf86j8tjm90wq77c7ende4e5qmrerq4xsg02vhq9qxpqysgqjltywgyk6uc5qcgwh8xnzmawl2tjlhz8d28tgp3yx8xwtz76x0jqkfh6mmq70hervjxs0keun7ur0spldgll29l0dnz3md50d65sfqqqwrwpsu".parse().unwrap() }, cashu::CurrencyUnit::Sat, 33.into(), Amount::ZERO, 0, None, None, cashu::PaymentMethod::Bolt11);
575+
tx1.add_melt_quote(quote.clone()).await.unwrap();
576+
tx1.add_melt_request(&quote.id, inputs_amount, inputs_fee)
577+
.await
578+
.unwrap();
579+
tx1.add_blinded_messages(Some(&quote.id), &blinded_messages)
580+
.await
581+
.unwrap();
582+
tx1.commit().await.unwrap();
583+
584+
// Simulate processing: get and delete
585+
let mut tx2 = Database::begin_transaction(&db).await.unwrap();
586+
let _retrieved = tx2
587+
.get_melt_request_and_blinded_messages(&quote.id)
588+
.await
589+
.unwrap()
590+
.unwrap();
591+
tx2.delete_melt_request(&quote.id).await.unwrap();
592+
tx2.commit().await.unwrap();
593+
594+
// Verify melt_request is deleted
595+
let mut tx3 = Database::begin_transaction(&db).await.unwrap();
596+
let retrieved = tx3
597+
.get_melt_request_and_blinded_messages(&quote.id)
598+
.await
599+
.unwrap();
600+
assert!(retrieved.is_none());
601+
tx3.commit().await.unwrap();
602+
}

crates/cdk-common/src/database/mint/test/mod.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,11 @@ macro_rules! mint_db_test {
235235
reject_over_issue_same_tx,
236236
reject_over_issue_different_tx,
237237
reject_over_issue_with_payment,
238-
reject_over_issue_with_payment_different_tx
238+
reject_over_issue_with_payment_different_tx,
239+
add_melt_request_unique_blinded_messages,
240+
reject_melt_duplicate_blinded_signature,
241+
reject_duplicate_blinded_message_db_constraint,
242+
cleanup_melt_request_after_processing
239243
);
240244
};
241245
($make_db_fn:ident, $($name:ident),+ $(,)?) => {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
-- Remove NOT NULL constraint from c column in blind_signature table
2+
ALTER TABLE blind_signature ALTER COLUMN c DROP NOT NULL;
3+
4+
-- Add signed_time column to blind_signature table
5+
ALTER TABLE blind_signature ADD COLUMN signed_time INTEGER NULL;
6+
7+
-- Update existing records to set signed_time equal to created_time for existing signatures
8+
UPDATE blind_signature SET signed_time = created_time WHERE c IS NOT NULL;
9+
10+
-- Insert data from blinded_messages table into blind_signature table with NULL c column
11+
INSERT INTO blind_signature (blinded_message, amount, keyset_id, c, quote_id, created_time, signed_time)
12+
SELECT blinded_message, amount, keyset_id, NULL as c, quote_id, 0 as created_time, NULL as signed_time
13+
FROM blinded_messages
14+
WHERE NOT EXISTS (
15+
SELECT 1 FROM blind_signature
16+
WHERE blind_signature.blinded_message = blinded_messages.blinded_message
17+
);
18+
19+
-- Create index on quote_id if it does not exist
20+
CREATE INDEX IF NOT EXISTS blind_signature_quote_id_index ON blind_signature(quote_id);
21+
22+
-- Drop the blinded_messages table as data has been migrated
23+
DROP TABLE IF EXISTS blinded_messages;
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
-- Remove NOT NULL constraint from c column in blind_signature table
2+
-- SQLite does not support ALTER COLUMN directly, so we need to recreate the table
3+
4+
-- Step 1 - Create new table with nullable c column and signed_time column
5+
CREATE TABLE blind_signature_new (
6+
blinded_message BLOB PRIMARY KEY,
7+
amount INTEGER NOT NULL,
8+
keyset_id TEXT NOT NULL,
9+
c BLOB NULL,
10+
dleq_e TEXT,
11+
dleq_s TEXT,
12+
quote_id TEXT,
13+
created_time INTEGER NOT NULL DEFAULT 0,
14+
signed_time INTEGER
15+
);
16+
17+
-- Step 2 - Copy existing data from old blind_signature table
18+
INSERT INTO blind_signature_new (blinded_message, amount, keyset_id, c, dleq_e, dleq_s, quote_id, created_time)
19+
SELECT blinded_message, amount, keyset_id, c, dleq_e, dleq_s, quote_id, created_time
20+
FROM blind_signature;
21+
22+
-- Step 3 - Insert data from blinded_messages table with NULL c column
23+
INSERT INTO blind_signature_new (blinded_message, amount, keyset_id, c, quote_id, created_time)
24+
SELECT blinded_message, amount, keyset_id, NULL as c, quote_id, 0 as created_time
25+
FROM blinded_messages
26+
WHERE NOT EXISTS (
27+
SELECT 1 FROM blind_signature_new
28+
WHERE blind_signature_new.blinded_message = blinded_messages.blinded_message
29+
);
30+
31+
-- Step 4 - Drop old table and rename new table
32+
DROP TABLE blind_signature;
33+
ALTER TABLE blind_signature_new RENAME TO blind_signature;
34+
35+
-- Step 5 - Recreate indexes
36+
CREATE INDEX IF NOT EXISTS keyset_id_index ON blind_signature(keyset_id);
37+
CREATE INDEX IF NOT EXISTS blind_signature_quote_id_index ON blind_signature(quote_id);
38+
39+
-- Step 6 - Drop the blinded_messages table as data has been migrated
40+
DROP TABLE IF EXISTS blinded_messages;

0 commit comments

Comments
 (0)