Add PostgreSQL support for mint and wallet (#878)

* Add PostgreSQL support for mint and wallet

* Fixed bug to avoid empty calls `get_proofs_states`

* Fixed SQL bug

* Avoid redudant clone()

* Add more tests for the storage layer

* Minor enhacements

* Add a generic function to execute db operations

This function would log slow operations and log errors

* Provision a postgres db for tests

* Update deps for msrv

* Add postgres to pipeline

* feat: add psgl to example and docker

* feat: db url fmt

---------

Co-authored-by: thesimplekid <tsk@thesimplekid.com>
This commit is contained in:
C
2025-08-18 13:45:11 -03:00
committed by GitHub
parent 2e424e629f
commit 28a01398fd
34 changed files with 1282 additions and 40 deletions

View File

@@ -182,7 +182,7 @@ where
/// Begin a transaction
async fn begin(conn: &mut W) -> Result<(), Error> {
query("BEGIN")?.execute(conn).await?;
query("START TRANSACTION")?.execute(conn).await?;
Ok(())
}

View File

@@ -1,6 +1,7 @@
/// @generated
/// Auto-generated by build.rs
pub static MIGRATIONS: &[(&str, &str, &str)] = &[
("postgres", "1_init.sql", include_str!(r#"./migrations/postgres/1_init.sql"#)),
("sqlite", "1_fix_sqlx_migration.sql", include_str!(r#"./migrations/sqlite/1_fix_sqlx_migration.sql"#)),
("sqlite", "20250109143347_init.sql", include_str!(r#"./migrations/sqlite/20250109143347_init.sql"#)),
];

View File

@@ -0,0 +1,43 @@
CREATE TABLE IF NOT EXISTS proof (
y BYTEA PRIMARY KEY,
keyset_id TEXT NOT NULL,
secret TEXT NOT NULL,
c BYTEA NOT NULL,
state TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS state_index ON proof(state);
CREATE INDEX IF NOT EXISTS secret_index ON proof(secret);
-- Keysets Table
CREATE TABLE IF NOT EXISTS keyset (
id TEXT PRIMARY KEY,
unit TEXT NOT NULL,
active BOOL NOT NULL,
valid_from INTEGER NOT NULL,
valid_to INTEGER,
derivation_path TEXT NOT NULL,
max_order INTEGER NOT NULL,
derivation_path_index INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS unit_index ON keyset(unit);
CREATE INDEX IF NOT EXISTS active_index ON keyset(active);
CREATE TABLE IF NOT EXISTS blind_signature (
y BYTEA PRIMARY KEY,
amount INTEGER NOT NULL,
keyset_id TEXT NOT NULL,
c BYTEA NOT NULL
);
CREATE INDEX IF NOT EXISTS keyset_id_index ON blind_signature(keyset_id);
CREATE TABLE IF NOT EXISTS protected_endpoints (
endpoint TEXT PRIMARY KEY,
auth TEXT NOT NULL
);

View File

@@ -262,9 +262,10 @@ where
FROM
keyset
WHERE
active = 1;
active = :active;
"#,
)?
.bind("active", true)
.pluck(&*conn)
.await?
.map(|id| Ok::<_, Error>(column_as_string!(id, Id::from_str, Id::from_bytes)))

View File

@@ -1,6 +1,7 @@
/// @generated
/// Auto-generated by build.rs
pub static MIGRATIONS: &[(&str, &str, &str)] = &[
("postgres", "1_initial.sql", include_str!(r#"./migrations/postgres/1_initial.sql"#)),
("sqlite", "1_fix_sqlx_migration.sql", include_str!(r#"./migrations/sqlite/1_fix_sqlx_migration.sql"#)),
("sqlite", "20240612124932_init.sql", include_str!(r#"./migrations/sqlite/20240612124932_init.sql"#)),
("sqlite", "20240618195700_quote_state.sql", include_str!(r#"./migrations/sqlite/20240618195700_quote_state.sql"#)),

View File

@@ -0,0 +1,100 @@
CREATE TABLE keyset (
id TEXT PRIMARY KEY, unit TEXT NOT NULL,
active BOOL NOT NULL, valid_from INTEGER NOT NULL,
valid_to INTEGER, derivation_path TEXT NOT NULL,
max_order INTEGER NOT NULL, input_fee_ppk INTEGER,
derivation_path_index INTEGER
);
CREATE INDEX unit_index ON keyset(unit);
CREATE INDEX active_index ON keyset(active);
CREATE TABLE melt_quote (
id TEXT PRIMARY KEY,
unit TEXT NOT NULL,
amount INTEGER NOT NULL,
request TEXT NOT NULL,
fee_reserve INTEGER NOT NULL,
expiry INTEGER NOT NULL,
state TEXT CHECK (
state IN ('UNPAID', 'PENDING', 'PAID')
) NOT NULL DEFAULT 'UNPAID',
payment_preimage TEXT,
request_lookup_id TEXT,
created_time INTEGER NOT NULL DEFAULT 0,
paid_time INTEGER,
payment_method TEXT NOT NULL DEFAULT 'bolt11',
options TEXT,
request_lookup_id_kind TEXT NOT NULL DEFAULT 'payment_hash'
);
CREATE INDEX melt_quote_state_index ON melt_quote(state);
CREATE UNIQUE INDEX unique_request_lookup_id_melt ON melt_quote(request_lookup_id);
CREATE TABLE melt_request (
id TEXT PRIMARY KEY, inputs TEXT NOT NULL,
outputs TEXT, method TEXT NOT NULL,
unit TEXT NOT NULL
);
CREATE TABLE config (
id TEXT PRIMARY KEY, value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS "proof" (
y BYTEA PRIMARY KEY,
amount INTEGER NOT NULL,
keyset_id TEXT NOT NULL,
secret TEXT NOT NULL,
c BYTEA NOT NULL,
witness TEXT,
state TEXT CHECK (
state IN (
'SPENT', 'PENDING', 'UNSPENT', 'RESERVED',
'UNKNOWN'
)
) NOT NULL,
quote_id TEXT,
created_time INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS "blind_signature" (
blinded_message BYTEA PRIMARY KEY,
amount INTEGER NOT NULL,
keyset_id TEXT NOT NULL,
c BYTEA NOT NULL,
dleq_e TEXT,
dleq_s TEXT,
quote_id TEXT,
created_time INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS "mint_quote" (
id TEXT PRIMARY KEY, amount INTEGER,
unit TEXT NOT NULL, request TEXT NOT NULL,
expiry INTEGER NOT NULL, request_lookup_id TEXT UNIQUE,
pubkey TEXT, created_time INTEGER NOT NULL DEFAULT 0,
amount_paid INTEGER NOT NULL DEFAULT 0,
amount_issued INTEGER NOT NULL DEFAULT 0,
payment_method TEXT NOT NULL DEFAULT 'BOLT11',
request_lookup_id_kind TEXT NOT NULL DEFAULT 'payment_hash'
);
CREATE INDEX idx_mint_quote_created_time ON mint_quote(created_time);
CREATE INDEX idx_mint_quote_expiry ON mint_quote(expiry);
CREATE INDEX idx_mint_quote_request_lookup_id ON mint_quote(request_lookup_id);
CREATE INDEX idx_mint_quote_request_lookup_id_and_kind ON mint_quote(
request_lookup_id, request_lookup_id_kind
);
CREATE TABLE mint_quote_payments (
id SERIAL PRIMARY KEY,
quote_id TEXT NOT NULL,
payment_id TEXT NOT NULL UNIQUE,
timestamp INTEGER NOT NULL,
amount INTEGER NOT NULL,
FOREIGN KEY (quote_id) REFERENCES mint_quote(id)
);
CREATE INDEX idx_mint_quote_payments_payment_id ON mint_quote_payments(payment_id);
CREATE INDEX idx_mint_quote_payments_quote_id ON mint_quote_payments(quote_id);
CREATE TABLE mint_quote_issued (
id SERIAL PRIMARY KEY,
quote_id TEXT NOT NULL,
amount INTEGER NOT NULL,
timestamp INTEGER NOT NULL,
FOREIGN KEY (quote_id) REFERENCES mint_quote(id)
);
CREATE INDEX idx_mint_quote_issued_quote_id ON mint_quote_issued(quote_id);
CREATE INDEX idx_melt_quote_request_lookup_id_and_kind ON mint_quote(
request_lookup_id, request_lookup_id_kind
);

View File

@@ -255,6 +255,9 @@ where
ys: &[PublicKey],
_quote_id: Option<Uuid>,
) -> Result<(), Self::Err> {
if ys.is_empty() {
return Ok(());
}
let total_deleted = query(
r#"
DELETE FROM proof WHERE y IN (:ys) AND state NOT IN (:exclude_state)
@@ -314,10 +317,15 @@ where
// Get payment IDs and timestamps from the mint_quote_payments table
query(
r#"
SELECT payment_id, timestamp, amount
FROM mint_quote_payments
WHERE quote_id=:quote_id;
"#,
SELECT
payment_id,
timestamp,
amount
FROM
mint_quote_payments
WHERE
quote_id=:quote_id
"#,
)?
.bind("quote_id", quote_id.as_hyphenated().to_string())
.fetch_all(conn)
@@ -407,12 +415,12 @@ where
}
async fn set_active_keyset(&mut self, unit: CurrencyUnit, id: Id) -> Result<(), Error> {
query(r#"UPDATE keyset SET active=FALSE WHERE unit IS :unit"#)?
query(r#"UPDATE keyset SET active=FALSE WHERE unit = :unit"#)?
.bind("unit", unit.to_string())
.execute(&self.inner)
.await?;
query(r#"UPDATE keyset SET active=TRUE WHERE unit IS :unit AND id IS :id"#)?
query(r#"UPDATE keyset SET active=TRUE WHERE unit = :unit AND id = :id"#)?
.bind("unit", unit.to_string())
.bind("id", id.to_string())
.execute(&self.inner)
@@ -443,7 +451,8 @@ where
async fn get_active_keyset_id(&self, unit: &CurrencyUnit) -> Result<Option<Id>, Self::Err> {
let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?;
Ok(
query(r#" SELECT id FROM keyset WHERE active = 1 AND unit IS :unit"#)?
query(r#" SELECT id FROM keyset WHERE active = :active AND unit = :unit"#)?
.bind("active", true)
.bind("unit", unit.to_string())
.pluck(&*conn)
.await?
@@ -458,17 +467,20 @@ where
async fn get_active_keysets(&self) -> Result<HashMap<CurrencyUnit, Id>, Self::Err> {
let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?;
Ok(query(r#"SELECT id, unit FROM keyset WHERE active = 1"#)?
.fetch_all(&*conn)
.await?
.into_iter()
.map(|row| {
Ok((
column_as_string!(&row[1], CurrencyUnit::from_str),
column_as_string!(&row[0], Id::from_str, Id::from_bytes),
))
})
.collect::<Result<HashMap<_, _>, Error>>()?)
Ok(
query(r#"SELECT id, unit FROM keyset WHERE active = :active"#)?
.bind("active", true)
.fetch_all(&*conn)
.await?
.into_iter()
.map(|row| {
Ok((
column_as_string!(&row[1], CurrencyUnit::from_str),
column_as_string!(&row[0], Id::from_str, Id::from_bytes),
))
})
.collect::<Result<HashMap<_, _>, Error>>()?,
)
}
async fn get_keyset_info(&self, id: &Id) -> Result<Option<MintKeySetInfo>, Self::Err> {
@@ -658,7 +670,6 @@ where
UPDATE mint_quote
SET amount_issued = :amount_issued
WHERE id = :quote_id
FOR UPDATE
"#,
)?
.bind("amount_issued", new_amount_issued.to_i64())

View File

@@ -1,6 +1,7 @@
/// @generated
/// Auto-generated by build.rs
pub static MIGRATIONS: &[(&str, &str, &str)] = &[
("postgres", "1_initial.sql", include_str!(r#"./migrations/postgres/1_initial.sql"#)),
("sqlite", "1_fix_sqlx_migration.sql", include_str!(r#"./migrations/sqlite/1_fix_sqlx_migration.sql"#)),
("sqlite", "20240612132920_init.sql", include_str!(r#"./migrations/sqlite/20240612132920_init.sql"#)),
("sqlite", "20240618200350_quote_state.sql", include_str!(r#"./migrations/sqlite/20240618200350_quote_state.sql"#)),

View File

@@ -0,0 +1,80 @@
CREATE TABLE mint (
mint_url TEXT PRIMARY KEY, name TEXT,
pubkey BYTEA, version TEXT, description TEXT,
description_long TEXT, contact TEXT,
nuts TEXT, motd TEXT, icon_url TEXT,
mint_time INTEGER, urls TEXT, tos_url TEXT
);
CREATE TABLE keyset (
id TEXT PRIMARY KEY,
mint_url TEXT NOT NULL,
unit TEXT NOT NULL,
active BOOL NOT NULL,
counter INTEGER NOT NULL DEFAULT 0,
input_fee_ppk INTEGER,
final_expiry INTEGER DEFAULT NULL,
FOREIGN KEY(mint_url) REFERENCES mint(mint_url) ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE melt_quote (
id TEXT PRIMARY KEY,
unit TEXT NOT NULL,
amount INTEGER NOT NULL,
request TEXT NOT NULL,
fee_reserve INTEGER NOT NULL,
expiry INTEGER NOT NULL,
state TEXT CHECK (
state IN ('UNPAID', 'PENDING', 'PAID')
) NOT NULL DEFAULT 'UNPAID',
payment_preimage TEXT
);
CREATE TABLE key (
id TEXT PRIMARY KEY, keys TEXT NOT NULL
);
CREATE INDEX melt_quote_state_index ON melt_quote(state);
CREATE TABLE IF NOT EXISTS "proof" (
y BYTEA PRIMARY KEY,
mint_url TEXT NOT NULL,
state TEXT CHECK (
state IN (
'SPENT', 'UNSPENT', 'PENDING', 'RESERVED',
'PENDING_SPENT'
)
) NOT NULL,
spending_condition TEXT,
unit TEXT NOT NULL,
amount INTEGER NOT NULL,
keyset_id TEXT NOT NULL,
secret TEXT NOT NULL,
c BYTEA NOT NULL,
witness TEXT,
dleq_e BYTEA,
dleq_s BYTEA,
dleq_r BYTEA
);
CREATE TABLE transactions (
id BYTEA PRIMARY KEY,
mint_url TEXT NOT NULL,
direction TEXT CHECK (
direction IN ('Incoming', 'Outgoing')
) NOT NULL,
amount INTEGER NOT NULL,
fee INTEGER NOT NULL,
unit TEXT NOT NULL,
ys BYTEA NOT NULL,
timestamp INTEGER NOT NULL,
memo TEXT,
metadata TEXT
);
CREATE INDEX mint_url_index ON transactions(mint_url);
CREATE INDEX direction_index ON transactions(direction);
CREATE INDEX unit_index ON transactions(unit);
CREATE INDEX timestamp_index ON transactions(timestamp);
CREATE TABLE IF NOT EXISTS "mint_quote" (
id TEXT PRIMARY KEY, mint_url TEXT NOT NULL,
payment_method TEXT NOT NULL DEFAULT 'bolt11',
amount INTEGER, unit TEXT NOT NULL,
request TEXT NOT NULL, state TEXT NOT NULL,
expiry INTEGER NOT NULL, amount_paid INTEGER NOT NULL DEFAULT 0,
amount_issued INTEGER NOT NULL DEFAULT 0,
secret_key TEXT
);