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
This commit is contained in:
thesimplekid
2025-09-27 23:51:24 +01:00
committed by GitHub
parent 6d0003a4fc
commit 4b04d10383
11 changed files with 692 additions and 55 deletions

View File

@@ -0,0 +1,23 @@
-- Remove NOT NULL constraint from c column in blind_signature table
ALTER TABLE blind_signature ALTER COLUMN c DROP NOT NULL;
-- Add signed_time column to blind_signature table
ALTER TABLE blind_signature ADD COLUMN signed_time INTEGER NULL;
-- Update existing records to set signed_time equal to created_time for existing signatures
UPDATE blind_signature SET signed_time = created_time WHERE c IS NOT NULL;
-- Insert data from blinded_messages table into blind_signature table with NULL c column
INSERT INTO blind_signature (blinded_message, amount, keyset_id, c, quote_id, created_time, signed_time)
SELECT blinded_message, amount, keyset_id, NULL as c, quote_id, 0 as created_time, NULL as signed_time
FROM blinded_messages
WHERE NOT EXISTS (
SELECT 1 FROM blind_signature
WHERE blind_signature.blinded_message = blinded_messages.blinded_message
);
-- Create index on quote_id if it does not exist
CREATE INDEX IF NOT EXISTS blind_signature_quote_id_index ON blind_signature(quote_id);
-- Drop the blinded_messages table as data has been migrated
DROP TABLE IF EXISTS blinded_messages;

View File

@@ -0,0 +1,40 @@
-- Remove NOT NULL constraint from c column in blind_signature table
-- SQLite does not support ALTER COLUMN directly, so we need to recreate the table
-- Step 1 - Create new table with nullable c column and signed_time column
CREATE TABLE blind_signature_new (
blinded_message BLOB PRIMARY KEY,
amount INTEGER NOT NULL,
keyset_id TEXT NOT NULL,
c BLOB NULL,
dleq_e TEXT,
dleq_s TEXT,
quote_id TEXT,
created_time INTEGER NOT NULL DEFAULT 0,
signed_time INTEGER
);
-- Step 2 - Copy existing data from old blind_signature table
INSERT INTO blind_signature_new (blinded_message, amount, keyset_id, c, dleq_e, dleq_s, quote_id, created_time)
SELECT blinded_message, amount, keyset_id, c, dleq_e, dleq_s, quote_id, created_time
FROM blind_signature;
-- Step 3 - Insert data from blinded_messages table with NULL c column
INSERT INTO blind_signature_new (blinded_message, amount, keyset_id, c, quote_id, created_time)
SELECT blinded_message, amount, keyset_id, NULL as c, quote_id, 0 as created_time
FROM blinded_messages
WHERE NOT EXISTS (
SELECT 1 FROM blind_signature_new
WHERE blind_signature_new.blinded_message = blinded_messages.blinded_message
);
-- Step 4 - Drop old table and rename new table
DROP TABLE blind_signature;
ALTER TABLE blind_signature_new RENAME TO blind_signature;
-- Step 5 - Recreate indexes
CREATE INDEX IF NOT EXISTS keyset_id_index ON blind_signature(keyset_id);
CREATE INDEX IF NOT EXISTS blind_signature_quote_id_index ON blind_signature(quote_id);
-- Step 6 - Drop the blinded_messages table as data has been migrated
DROP TABLE IF EXISTS blinded_messages;

View File

@@ -546,13 +546,13 @@ where
{
type Err = Error;
async fn add_melt_request_and_blinded_messages(
async fn add_melt_request(
&mut self,
quote_id: &QuoteId,
inputs_amount: Amount,
inputs_fee: Amount,
blinded_messages: &[BlindedMessage],
) -> Result<(), Self::Err> {
// Insert melt_request
query(
r#"
INSERT INTO melt_request
@@ -567,29 +567,82 @@ where
.execute(&self.inner)
.await?;
Ok(())
}
async fn add_blinded_messages(
&mut self,
quote_id: Option<&QuoteId>,
blinded_messages: &[BlindedMessage],
) -> Result<(), Self::Err> {
let current_time = unix_time();
// Insert blinded_messages directly into blind_signature with c = NULL
// Let the database constraint handle duplicate detection
for message in blinded_messages {
query(
match query(
r#"
INSERT INTO blinded_messages
(quote_id, blinded_message, keyset_id, amount)
INSERT INTO blind_signature
(blinded_message, amount, keyset_id, c, quote_id, created_time)
VALUES
(:quote_id, :blinded_message, :keyset_id, :amount)
(:blinded_message, :amount, :keyset_id, NULL, :quote_id, :created_time)
"#,
)?
.bind("quote_id", quote_id.to_string())
.bind(
"blinded_message",
message.blinded_secret.to_bytes().to_vec(),
)
.bind("keyset_id", message.keyset_id.to_string())
.bind("amount", message.amount.to_i64())
.bind("keyset_id", message.keyset_id.to_string())
.bind("quote_id", quote_id.map(|q| q.to_string()))
.bind("created_time", current_time as i64)
.execute(&self.inner)
.await?;
.await
{
Ok(_) => continue,
Err(database::Error::Duplicate) => {
// Primary key constraint violation - blinded message already exists
// This could be either:
// 1. Already signed (c IS NOT NULL) - definitely an error
// 2. Already pending (c IS NULL) - also an error
return Err(database::Error::Duplicate);
}
Err(err) => return Err(err),
}
}
Ok(())
}
async fn delete_blinded_messages(
&mut self,
blinded_secrets: &[PublicKey],
) -> Result<(), Self::Err> {
if blinded_secrets.is_empty() {
return Ok(());
}
// Delete blinded messages from blind_signature table where c IS NULL
// (only delete unsigned blinded messages)
query(
r#"
DELETE FROM blind_signature
WHERE blinded_message IN (:blinded_secrets) AND c IS NULL
"#,
)?
.bind_vec(
"blinded_secrets",
blinded_secrets
.iter()
.map(|secret| secret.to_bytes().to_vec())
.collect(),
)
.execute(&self.inner)
.await?;
Ok(())
}
async fn get_melt_request_and_blinded_messages(
&mut self,
quote_id: &QuoteId,
@@ -610,11 +663,12 @@ where
let inputs_amount: u64 = column_as_number!(row[0].clone());
let inputs_fee: u64 = column_as_number!(row[1].clone());
// Get blinded messages from blind_signature table where c IS NULL
let blinded_messages_rows = query(
r#"
SELECT blinded_message, keyset_id, amount
FROM blinded_messages
WHERE quote_id = :quote_id
FROM blind_signature
WHERE quote_id = :quote_id AND c IS NULL
"#,
)?
.bind("quote_id", quote_id.to_string())
@@ -650,6 +704,7 @@ where
}
async fn delete_melt_request(&mut self, quote_id: &QuoteId) -> Result<(), Self::Err> {
// Delete from melt_request table
query(
r#"
DELETE FROM melt_request
@@ -660,6 +715,17 @@ where
.execute(&self.inner)
.await?;
// Also delete blinded messages (where c IS NULL) from blind_signature table
query(
r#"
DELETE FROM blind_signature
WHERE quote_id = :quote_id AND c IS NULL
"#,
)?
.bind("quote_id", quote_id.to_string())
.execute(&self.inner)
.await?;
Ok(())
}
@@ -1560,34 +1626,122 @@ where
) -> Result<(), Self::Err> {
let current_time = unix_time();
if blinded_messages.len() != blind_signatures.len() {
return Err(database::Error::Internal(
"Mismatched array lengths for blinded messages and blind signatures".to_string(),
));
}
// Select all existing rows for the given blinded messages at once
let mut existing_rows = query(
r#"
SELECT blinded_message, c, dleq_e, dleq_s
FROM blind_signature
WHERE blinded_message IN (:blinded_messages)
FOR UPDATE
"#,
)?
.bind_vec(
"blinded_messages",
blinded_messages
.iter()
.map(|message| message.to_bytes().to_vec())
.collect(),
)
.fetch_all(&self.inner)
.await?
.into_iter()
.map(|mut row| {
Ok((
column_as_string!(&row.remove(0), PublicKey::from_hex, PublicKey::from_slice),
(row[0].clone(), row[1].clone(), row[2].clone()),
))
})
.collect::<Result<HashMap<_, _>, Error>>()?;
// Iterate over the provided blinded messages and signatures
for (message, signature) in blinded_messages.iter().zip(blind_signatures) {
query(
r#"
INSERT INTO blind_signature
(blinded_message, amount, keyset_id, c, quote_id, dleq_e, dleq_s, created_time)
VALUES
(:blinded_message, :amount, :keyset_id, :c, :quote_id, :dleq_e, :dleq_s, :created_time)
"#,
)?
.bind("blinded_message", message.to_bytes().to_vec())
.bind("amount", u64::from(signature.amount) as i64)
.bind("keyset_id", signature.keyset_id.to_string())
.bind("c", signature.c.to_bytes().to_vec())
.bind("quote_id", quote_id.as_ref().map(|q| match q {
QuoteId::BASE64(s) => s.to_string(),
QuoteId::UUID(u) => u.hyphenated().to_string(),
}))
.bind(
"dleq_e",
signature.dleq.as_ref().map(|dleq| dleq.e.to_secret_hex()),
)
.bind(
"dleq_s",
signature.dleq.as_ref().map(|dleq| dleq.s.to_secret_hex()),
)
.bind("created_time", current_time as i64)
.execute(&self.inner)
.await?;
match existing_rows.remove(message) {
None => {
// Unknown blind message: Insert new row with all columns
query(
r#"
INSERT INTO blind_signature
(blinded_message, amount, keyset_id, c, quote_id, dleq_e, dleq_s, created_time, signed_time)
VALUES
(:blinded_message, :amount, :keyset_id, :c, :quote_id, :dleq_e, :dleq_s, :created_time, :signed_time)
"#,
)?
.bind("blinded_message", message.to_bytes().to_vec())
.bind("amount", u64::from(signature.amount) as i64)
.bind("keyset_id", signature.keyset_id.to_string())
.bind("c", signature.c.to_bytes().to_vec())
.bind("quote_id", quote_id.as_ref().map(|q| q.to_string()))
.bind(
"dleq_e",
signature.dleq.as_ref().map(|dleq| dleq.e.to_secret_hex()),
)
.bind(
"dleq_s",
signature.dleq.as_ref().map(|dleq| dleq.s.to_secret_hex()),
)
.bind("created_time", current_time as i64)
.bind("signed_time", current_time as i64)
.execute(&self.inner)
.await?;
}
Some((c, _dleq_e, _dleq_s)) => {
// Blind message exists: check if c is NULL
match c {
Column::Null => {
// Blind message with no c: Update with missing columns c, dleq_e, dleq_s
query(
r#"
UPDATE blind_signature
SET c = :c, dleq_e = :dleq_e, dleq_s = :dleq_s, signed_time = :signed_time, amount = :amount
WHERE blinded_message = :blinded_message
"#,
)?
.bind("c", signature.c.to_bytes().to_vec())
.bind(
"dleq_e",
signature.dleq.as_ref().map(|dleq| dleq.e.to_secret_hex()),
)
.bind(
"dleq_s",
signature.dleq.as_ref().map(|dleq| dleq.s.to_secret_hex()),
)
.bind("blinded_message", message.to_bytes().to_vec())
.bind("signed_time", current_time as i64)
.bind("amount", u64::from(signature.amount) as i64)
.execute(&self.inner)
.await?;
}
_ => {
// Blind message already has c: Error
tracing::error!(
"Attempting to add signature to message already signed {}",
message
);
return Err(database::Error::Duplicate);
}
}
}
}
}
debug_assert!(
existing_rows.is_empty(),
"Unexpected existing rows remain: {:?}",
existing_rows.keys().collect::<Vec<_>>()
);
if !existing_rows.is_empty() {
tracing::error!("Did not check all existing rows");
return Err(Error::Internal(
"Did not check all existing rows".to_string(),
));
}
Ok(())
@@ -1607,14 +1761,14 @@ where
blinded_message
FROM
blind_signature
WHERE blinded_message IN (:y)
WHERE blinded_message IN (:b) AND c IS NOT NULL
"#,
)?
.bind_vec(
"y",
"b",
blinded_messages
.iter()
.map(|y| y.to_bytes().to_vec())
.map(|b| b.to_bytes().to_vec())
.collect(),
)
.fetch_all(&self.inner)
@@ -1660,11 +1814,11 @@ where
blinded_message
FROM
blind_signature
WHERE blinded_message IN (:blinded_message)
WHERE blinded_message IN (:b) AND c IS NOT NULL
"#,
)?
.bind_vec(
"blinded_message",
"b",
blinded_messages
.iter()
.map(|b_| b_.to_bytes().to_vec())
@@ -1706,7 +1860,7 @@ where
FROM
blind_signature
WHERE
keyset_id=:keyset_id
keyset_id=:keyset_id AND c IS NOT NULL
"#,
)?
.bind("keyset_id", keyset_id.to_string())
@@ -1734,7 +1888,7 @@ where
FROM
blind_signature
WHERE
quote_id=:quote_id
quote_id=:quote_id AND c IS NOT NULL
"#,
)?
.bind("quote_id", quote_id.to_string())