Skip to content

Commit 47b3898

Browse files
committed
feat(mint): enforce journaling with a JournaledDatabase decorator
The append-only journal was populated by hand: every state mutation in the mint orchestration was paired with an explicit add_journal call in the same transaction. Nothing tied a mutation to its journal write, so a new or edited mutation path could silently produce an incomplete journal, and one production site already did (the signatory's boot-time keyset re-activation in init_keysets emitted no event). Auditing and replay are only as trustworthy as the journal is complete, so completeness must be structural, not a convention that each call site is trusted to follow. Make journaling a property of the storage layer. JournaledDatabase<D> wraps a mint database and, for every mutation of a journaled entity (mint quote, melt quote, proof, blind signature, keyset), appends the matching event on the same transaction as the mutation. The append runs on the inner transaction's own connection, so the journal row commits or rolls back atomically with the state it records. The decorator lives in cdk-common, so a single implementation journals for every backend (sqlite, postgres, supabase) instead of duplicating the logic in each store. Generic over an unsized D held behind an Arc, the wrapper accepts either a concrete database or an existing trait object. Wrapping the DynMintDatabase handle the mint already threads around lets journaling be installed at two construction choke points, Mint::new_internal and DbSignatory::new, rather than at every backend or test. All mints and signatories pass through those, so the decorator is the only way to open a transaction. The orchestration layer no longer calls add_journal anywhere: the manual pairs in the issue, melt, swap, and rollback flows and the signatory keyset flow are removed, along with the now-redundant pre-read that fetched the superseded keyset. add_journal remains on the transaction trait as the internal primitive the decorator invokes on the inner transaction, but the decorator's own add_journal, on both the main and keyset transactions, rejects direct calls with a new Error::JournalNotPermitted. Because the decorator's mutation methods journal through the inner transaction and never through themselves, that rejection is unreachable internally and fires only when code outside the decorator tries to write the journal directly, which would produce an unmanaged, possibly duplicate row. Two mutations carry more than a plain column value, so the decorator derives their events rather than copying an argument: - update_mint_quote drains a change buffer (take_changes), so the wrapper peeks the pending payments and issuances through a new non-draining accessor on MintQuote before delegating, then journals each from a clone once the persist succeeds. This keeps the per-payment and per-issuance deltas, which are richer than the amount_paid and amount_issued totals the row stores. - set_active_keyset deactivates the keyset it supersedes, whose identity is not one of its arguments. The keyset wrapper captures the active-keyset map when the transaction opens and journals the deactivation of the previous keyset from it. Wrapping the signatory keystore before init_keysets means the boot-time re-activation is now journaled too, closing the pre-existing gap. Serde and the event vocabulary are unchanged; the decorator emits the same events the hand-written calls did, verified by the existing flow and replay tests passing without modification. New tests drive two keyset rotations plus a reboot, asserting the supersede deactivation and the boot re-activation are journaled, and assert that a direct add_journal on either wrapped transaction is rejected. A non-draining pending_changes accessor is added to MintQuote for the change-buffer peek.
1 parent 1be2f41 commit 47b3898

14 files changed

Lines changed: 1017 additions & 148 deletions

File tree

crates/cdk-common/src/database/journaled.rs

Lines changed: 797 additions & 0 deletions
Large diffs are not rendered by default.

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
33
#[cfg(feature = "mint")]
44
pub mod event_log;
5+
#[cfg(feature = "mint")]
6+
mod journaled;
57
mod kvstore;
68

79
#[cfg(feature = "mint")]
@@ -21,6 +23,8 @@ pub type DynKVStore = std::sync::Arc<dyn KVStore<Err = Error> + Send + Sync>;
2123
#[cfg(feature = "mint")]
2224
pub use event_log::{generate_id, init_event_id_generator, Delta, Event, Snapshot};
2325
#[cfg(feature = "mint")]
26+
pub use journaled::JournaledDatabase;
27+
#[cfg(feature = "mint")]
2428
pub use mint::JournalTransaction;
2529
#[cfg(feature = "mint")]
2630
pub use mint::{
@@ -226,6 +230,15 @@ pub enum Error {
226230
/// Concurrent update detected
227231
#[error("Concurrent update detected")]
228232
ConcurrentUpdate,
233+
234+
/// Direct journal write attempted through the journaling decorator.
235+
///
236+
/// `add_journal` is an internal primitive: entity mutations journal
237+
/// themselves automatically through [`JournaledDatabase`]. Calling it
238+
/// directly on a wrapped transaction is a programming error and is rejected.
239+
#[cfg(feature = "mint")]
240+
#[error("direct journal writes are not permitted; entity mutations journal automatically")]
241+
JournalNotPermitted,
229242
}
230243

231244
#[cfg(feature = "mint")]

crates/cdk-common/src/mint.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,17 @@ impl MintQuote {
730730
self.changes.take()
731731
}
732732

733+
/// Borrows the pending changes without draining them.
734+
///
735+
/// [`Self::take_changes`] both reads and clears the change buffer, which the
736+
/// database layer relies on when persisting. The journaling decorator needs
737+
/// to see the same payments and issuances to derive its events, but must not
738+
/// consume them before `update_mint_quote` runs, so it peeks through this
739+
/// accessor and journals from a clone after the persist succeeds.
740+
pub(crate) fn pending_changes(&self) -> Option<&MintQuoteChange> {
741+
self.changes.as_ref()
742+
}
743+
733744
/// Records a new payment received for this mint quote.
734745
///
735746
/// This method validates the payment, updates the quote's internal state, and records the

crates/cdk-signatory/src/db_signatory.rs

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use std::sync::Arc;
66

77
use bitcoin::bip32::{DerivationPath, Xpriv};
88
use bitcoin::secp256k1::{self, Secp256k1};
9-
use cdk_common::database::{self, Delta};
9+
use cdk_common::database::{self, JournaledDatabase};
1010
use cdk_common::dhke::{sign_message, verify_message};
1111
use cdk_common::mint::MintKeySetInfo;
1212
use cdk_common::nuts::{BlindSignature, BlindedMessage, CurrencyUnit, Id, MintKeySet, Proof};
@@ -48,6 +48,12 @@ impl DbSignatory {
4848
mut supported_units: HashMap<CurrencyUnit, (u64, Vec<u64>)>,
4949
custom_paths: HashMap<CurrencyUnit, DerivationPath>,
5050
) -> Result<Self, Error> {
51+
// Wrap the keystore so keyset creation and activation are journaled
52+
// automatically, including the boot-time re-activation done by
53+
// `init_keysets` below.
54+
let localstore: Arc<dyn database::MintKeysDatabase<Err = database::Error> + Send + Sync> =
55+
Arc::new(JournaledDatabase::new(localstore));
56+
5157
let secp_ctx = Secp256k1::new();
5258
let xpriv = Xpriv::new_master(bitcoin::Network::Bitcoin, seed).expect("RNG busted");
5359
init_keysets(xpriv, &secp_ctx, &localstore, &supported_units).await?;
@@ -236,22 +242,10 @@ impl Signatory for DbSignatory {
236242
let keysets = self.keysets().await?;
237243
check_unit_string_collision(keysets.keysets, &info)?;
238244

239-
// Capture the keyset being superseded so its deactivation is journaled.
240-
let previous_active = self.localstore.get_active_keyset_id(&args.unit).await?;
241-
242245
let id = info.id;
243246
let mut tx = self.localstore.begin_transaction().await?;
244247
tx.add_keyset_info(info.clone()).await?;
245-
tx.add_journal(id.to_string(), info.clone().into()).await?;
246248
tx.set_active_keyset(args.unit, id).await?;
247-
if let Some(previous) = previous_active {
248-
if previous != id {
249-
tx.add_journal(previous.to_string(), Delta::KeysetActive(false).into())
250-
.await?;
251-
}
252-
}
253-
tx.add_journal(id.to_string(), Delta::KeysetActive(true).into())
254-
.await?;
255249
tx.commit().await?;
256250

257251
self.reload_keys_from_db().await?;

crates/cdk/src/mint/issue/mod.rs

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
use std::sync::Arc;
22

3-
use cdk_common::database::event_log::Delta;
43
use cdk_common::database::mint::Acquired;
54
use cdk_common::mint::{MintQuote, Operation};
65
use cdk_common::payment::{
@@ -363,8 +362,6 @@ impl Mint {
363362

364363
let mut tx = self.localstore.begin_transaction().await?;
365364
tx.add_mint_quote(quote.clone()).await?;
366-
tx.add_journal(quote.id.to_string(), quote.clone().into())
367-
.await?;
368365
tx.commit().await?;
369366

370367
if payment_method.is_bolt11() {
@@ -912,10 +909,6 @@ impl Mint {
912909
.await?;
913910
tx.add_blind_signatures(&blinded_secrets, &all_blind_signatures, None)
914911
.await?;
915-
for (secret, signature) in blinded_secrets.iter().zip(all_blind_signatures.iter()) {
916-
tx.add_journal(secret.to_hex(), signature.clone().into())
917-
.await?;
918-
}
919912
let fee_by_keyset = std::collections::HashMap::new();
920913
tx.add_completed_operation(&batch_operation, &fee_by_keyset)
921914
.await?;
@@ -966,21 +959,10 @@ impl Mint {
966959
Some(quote_id.clone()),
967960
)
968961
.await?;
969-
for (secret, signature) in
970-
blinded_secrets.iter().zip(all_blind_signatures.iter())
971-
{
972-
tx.add_journal(secret.to_hex(), signature.clone().into())
973-
.await?;
974-
}
975962
}
976963

977964
mint_quote.add_issuance(amount_issued.clone())?;
978965
tx.update_mint_quote(&mut mint_quote).await?;
979-
tx.add_journal(
980-
mint_quote.id.to_string(),
981-
Delta::MintQuoteIssuance(amount_issued.into()).into(),
982-
)
983-
.await?;
984966

985967
// Mint operations have no input fees
986968
// Only persist operation for non-batch mints (batch operations are persisted above)
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
//! End-to-end checks that keyset creation, activation, and boot-time
2+
//! re-activation emit append-only journal events through the journaling
3+
//! database wrapper.
4+
//!
5+
//! These drive [`DbSignatory`] directly (with a fixed seed and a file-backed
6+
//! sqlite database) so the boot-time re-activation path in `init_keysets` is
7+
//! exercised deterministically. That path previously wrote no journal event; it
8+
//! must now, because journaling is a property of the storage layer.
9+
10+
use std::collections::HashMap;
11+
use std::sync::Arc;
12+
13+
use cdk_common::database::event_log::{Delta, Entity, Event, Snapshot};
14+
use cdk_common::nut02::KeySetVersion;
15+
use cdk_common::nuts::CurrencyUnit;
16+
use cdk_signatory::db_signatory::DbSignatory;
17+
use cdk_signatory::signatory::{RotateKeyArguments, Signatory};
18+
19+
use crate::test_helpers::mint::read_journal;
20+
21+
const SEED: &[u8] = b"keyset-journal-test-fixed-seed-000000000000";
22+
23+
fn sat_units() -> HashMap<CurrencyUnit, (u64, Vec<u64>)> {
24+
let mut units = HashMap::new();
25+
units.insert(CurrencyUnit::Sat, (0u64, vec![1, 2, 4, 8]));
26+
units
27+
}
28+
29+
fn rotate_args() -> RotateKeyArguments {
30+
RotateKeyArguments {
31+
unit: CurrencyUnit::Sat,
32+
amounts: vec![1, 2, 4, 8],
33+
input_fee_ppk: 0,
34+
keyset_id_type: KeySetVersion::Version00,
35+
final_expiry: None,
36+
}
37+
}
38+
39+
async fn open_db(file: &str) -> Arc<cdk_sqlite::mint::MintSqliteDatabase> {
40+
Arc::new(
41+
cdk_sqlite::mint::MintSqliteDatabase::new(file)
42+
.await
43+
.expect("file-backed mint db"),
44+
)
45+
}
46+
47+
/// Rotating a keyset twice journals a snapshot and activation for each keyset,
48+
/// plus a deactivation of the keyset that the second rotation supersedes.
49+
/// Re-opening the database with the same seed re-activates the highest-index
50+
/// keyset at boot, which must append its own journal events.
51+
#[tokio::test]
52+
async fn keyset_rotation_and_boot_reactivation_are_journaled() {
53+
let file = format!(
54+
"{}/cdk_journal_keyset_test.sqlite",
55+
std::env::temp_dir().display()
56+
);
57+
let _ = std::fs::remove_file(&file);
58+
59+
// First signatory: create two keysets; the second supersedes the first.
60+
let (first_id, second_id) = {
61+
let signatory = DbSignatory::new(open_db(&file).await, SEED, sat_units(), HashMap::new())
62+
.await
63+
.expect("signatory");
64+
65+
let first = signatory
66+
.rotate_keyset(rotate_args())
67+
.await
68+
.expect("rotate 1");
69+
let second = signatory
70+
.rotate_keyset(rotate_args())
71+
.await
72+
.expect("rotate 2");
73+
(first.id, second.id)
74+
};
75+
76+
let after_rotations = read_journal(&file);
77+
78+
// Both keysets have a creation snapshot.
79+
for id in [first_id, second_id] {
80+
let record = id.to_string();
81+
assert!(
82+
after_rotations.iter().any(|(entity, r, e)| *entity == Entity::Keyset
83+
&& *r == record
84+
&& matches!(e, Event::Snapshot(s) if matches!(s.as_ref(), Snapshot::Keyset(_)))),
85+
"missing keyset snapshot for {record}, journal: {after_rotations:?}"
86+
);
87+
}
88+
89+
// The second rotation deactivates the first keyset.
90+
let first_record = first_id.to_string();
91+
assert!(
92+
after_rotations
93+
.iter()
94+
.any(|(entity, r, e)| *entity == Entity::Keyset
95+
&& *r == first_record
96+
&& matches!(e, Event::Delta(Delta::KeysetActive(false)))),
97+
"superseded keyset must be journaled as deactivated, journal: {after_rotations:?}"
98+
);
99+
100+
// The second keyset is activated.
101+
let second_record = second_id.to_string();
102+
assert!(
103+
after_rotations
104+
.iter()
105+
.any(|(entity, r, e)| *entity == Entity::Keyset
106+
&& *r == second_record
107+
&& matches!(e, Event::Delta(Delta::KeysetActive(true)))),
108+
"new keyset must be journaled as activated, journal: {after_rotations:?}"
109+
);
110+
111+
// Second signatory on the same database and seed: `init_keysets` re-activates
112+
// the highest-index keyset at boot. This path emitted no journal event before
113+
// journaling moved into the storage layer; it must now.
114+
{
115+
let _signatory = DbSignatory::new(open_db(&file).await, SEED, sat_units(), HashMap::new())
116+
.await
117+
.expect("reboot signatory");
118+
}
119+
120+
let after_reboot = read_journal(&file);
121+
assert!(
122+
after_reboot.len() > after_rotations.len(),
123+
"boot-time keyset re-activation must append journal events (was {}, now {})",
124+
after_rotations.len(),
125+
after_reboot.len()
126+
);
127+
assert!(
128+
after_reboot[after_rotations.len()..]
129+
.iter()
130+
.all(|(entity, _, _)| *entity == Entity::Keyset),
131+
"the new boot events must all be keyset entries, tail: {:?}",
132+
&after_reboot[after_rotations.len()..]
133+
);
134+
135+
let _ = std::fs::remove_file(&file);
136+
}
137+
138+
/// The decorator rejects direct `add_journal` calls on both transaction kinds:
139+
/// journaling is driven by the entity mutations, so a direct write from outside
140+
/// the decorator is a programming error and must fail rather than produce an
141+
/// unmanaged journal row.
142+
#[tokio::test]
143+
async fn direct_add_journal_through_wrapper_is_rejected() {
144+
use cdk_common::database::event_log::{Delta, Event};
145+
use cdk_common::database::{
146+
Error as DbError, JournaledDatabase, MintDatabase, MintKeysDatabase,
147+
};
148+
149+
// Mint transaction path.
150+
let journaled = JournaledDatabase::new(Arc::new(
151+
cdk_sqlite::mint::memory::empty().await.expect("mint db"),
152+
));
153+
let mut tx = MintDatabase::begin_transaction(&journaled)
154+
.await
155+
.expect("begin mint tx");
156+
let err = tx
157+
.add_journal("record".to_string(), Event::Delta(Delta::ProofRemoved))
158+
.await
159+
.expect_err("direct add_journal must fail");
160+
assert!(matches!(err, DbError::JournalNotPermitted), "got {err:?}");
161+
tx.rollback().await.expect("rollback mint tx");
162+
163+
// Keyset transaction path.
164+
let journaled_keys = JournaledDatabase::new(Arc::new(
165+
cdk_sqlite::mint::memory::empty().await.expect("keys db"),
166+
));
167+
let mut ktx = MintKeysDatabase::begin_transaction(&journaled_keys)
168+
.await
169+
.expect("begin keys tx");
170+
let kerr = ktx
171+
.add_journal(
172+
"record".to_string(),
173+
Event::Delta(Delta::KeysetActive(true)),
174+
)
175+
.await
176+
.expect_err("direct add_journal must fail");
177+
assert!(matches!(kerr, DbError::JournalNotPermitted), "got {kerr:?}");
178+
ktx.rollback().await.expect("rollback keys tx");
179+
}

crates/cdk/src/mint/ln.rs

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ use std::collections::HashMap;
22
use std::sync::Arc;
33

44
use cdk_common::common::PaymentProcessorKey;
5-
use cdk_common::database::event_log::Delta;
65
use cdk_common::database::DynMintDatabase;
76
use cdk_common::mint::MintQuote;
87
use cdk_common::payment::DynMintPayment;
@@ -87,13 +86,6 @@ impl Mint {
8786
match new_quote.add_payment(amount_paid, payment.payment_id.clone(), None) {
8887
Ok(()) => {
8988
tx.update_mint_quote(&mut new_quote).await?;
90-
if let Some(incoming) = new_quote.payments.last() {
91-
tx.add_journal(
92-
new_quote.id.to_string(),
93-
Delta::MintQuotePayment(incoming.clone()).into(),
94-
)
95-
.await?;
96-
}
9789
should_notify = true;
9890
}
9991
Err(crate::Error::DuplicatePaymentId) => {

0 commit comments

Comments
 (0)