Skip to content

Migrate payjoin-cli from sled to rusqlite#873

Merged
arminsabouri merged 2 commits into
payjoin:masterfrom
Mshehu5:rusqlite_migration
Aug 27, 2025
Merged

Migrate payjoin-cli from sled to rusqlite#873
arminsabouri merged 2 commits into
payjoin:masterfrom
Mshehu5:rusqlite_migration

Conversation

@Mshehu5

@Mshehu5 Mshehu5 commented Jul 13, 2025

Copy link
Copy Markdown
Contributor

Description

This PR migrates the persistence layer in payjoin-cli from the sled embedded database to rusqlite (v0.31.0), using r2d2 for connection pooling. The migration aims to improve reliability, enable structured relational data handling, and align the project with other ecosystem tools such as Liana and BullBitcoin.

This change addresses issue #867, making payjoin-cli a more robust reference implementation for downstream wallet developers.

Key Changes

Persistence Layer

  • Replaced sled with rusqlite (v0.31.0) and r2d2_sqlite (v0.24.0).
  • Refactored all session-related persistence code to interact with SQLite.
  • Removed all references to sled and its dependent types.

Relational Schema (SQLite)

  • sessions: Tracks session ID (BLOB primary key) and completed_at timestamp.
  • session_events: Logs session events; includes a sortable created_at timestamp and a foreign key to sessions.
  • inputs_seen: Stores seen UTXOs with created_at, to support input deduplication.

Session ID Redesign

  • Replaced variable-length session IDs with fixed 8-byte identifiers.
  • Uses deterministic hashing of system time and thread ID to ensure uniqueness.
  • Ensures compatibility with SQLite’s binary primary key requirements.

Error Handling

  • Removed unused error variants: NotFound, TryFromSlice.
  • Unified deserialization errors with From<serde_json::Error> for better classification.

Configuration Updates

  • Default database path changed from payjoin.sled to payjoin.sqlite.
  • Config files and internal documentation updated accordingly.

Impact

  • No major architectural or breaking changes introduced.
  • External session interfaces remain intact; downstream consumers should not need updates.

Testing

  • Verified full behavior of SQLite-backed session persister:
    • Session creation, saving, querying
    • Session events are correctly timestamped and retrieved in order
  • Tested locally: Both sender and receiver works properly

Closes #867

@coveralls

coveralls commented Jul 13, 2025

Copy link
Copy Markdown
Collaborator

Pull Request Test Coverage Report for Build 17267118353

Details

  • 151 of 176 (85.8%) changed or added relevant lines in 3 files are covered.
  • 1 unchanged line in 1 file lost coverage.
  • Overall coverage decreased (-0.1%) to 85.708%

Changes Missing Coverage Covered Lines Changed/Added Lines %
payjoin-cli/src/db/v2.rs 95 99 95.96%
payjoin-cli/src/db/mod.rs 56 61 91.8%
payjoin-cli/src/db/error.rs 0 16 0.0%
Files with Coverage Reduction New Missed Lines %
payjoin/src/core/version.rs 1 81.25%
Totals Coverage Status
Change from base Build 17255673359: -0.1%
Covered Lines: 7976
Relevant Lines: 9306

💛 - Coveralls

@nothingmuch nothingmuch left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks great. I appreciate the attention to detail updating the docs and examples.

I have a few suggestions for the DB schema, mainly I don't think SessionId::generate() is the right approach, although hashing low entropy values like time and thread IDs is unlikely to collide since a hash function is applied it's not guaranteed. If we go that route UUIDs would be better, but I would much prefer if we relied on the database to generate sequence numbers, i.e. INTEGER PRIMARY KEY AUTOINCREMENT

This is only an initial round of comments, I only skimmed the code and focused on the DB schema for now

Comment thread payjoin-cli/src/db/mod.rs Outdated
Comment thread payjoin-cli/src/db/v2.rs Outdated
Comment thread payjoin-cli/src/db/v2.rs Outdated
Comment thread payjoin-cli/src/db/v2.rs
Comment thread payjoin-cli/src/db/mod.rs Outdated
@nothingmuch

Copy link
Copy Markdown
Contributor

Also, since we're before a 1.0.0 release, I think we can skip any kind of migration by supporting both databases, but then we should probably document somewhere that any ongoing sessions with the old DB need to be completed with the old version

Alternatively something to dump session events out of sled and into sqlite could be considered, but i would prefer the simpler approach of not supporting that, since sessions expire it's not a big deal IMO.

@DanGould

Copy link
Copy Markdown
Member

Agree on deciding not to bother with migration

@arminsabouri arminsabouri left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couple code related comments. Overall this looks like the right approach.
Two other things:

  1. Could you please squash your commits into one. I noticed CI was failing for some of them. And Please try to make formatting/linting changes historically in the commit that introduce the problem.
  2. I am not sure rustqlite latest supports our MSRV(1.63) but version 29.0 does (rusqlite/rusqlite@cbf49b4)

Comment thread payjoin-cli/src/db/mod.rs Outdated
Comment thread payjoin-cli/src/db/mod.rs
Comment thread payjoin-cli/src/db/v2.rs Outdated
@nothingmuch

Copy link
Copy Markdown
Contributor
  1. I am not sure rustqlite latest supports our MSRV(1.63) but version 29.0 does (rusqlite/rusqlite@cbf49b4)

#867 (comment)

@Mshehu5
Mshehu5 force-pushed the rusqlite_migration branch 2 times, most recently from bbd523e to 69f40ef Compare July 19, 2025 21:24
@arminsabouri

Copy link
Copy Markdown
Contributor

Thanks for making the updates @Mshehu5 . I will take a look at this tomorrow. In the mean time can you please rebase agianst master and ensure the r2d2 dependencies does not break MSRV. Thanks

Comment thread payjoin-cli/src/db/mod.rs
@nothingmuch

nothingmuch commented Jul 22, 2025 via email

Copy link
Copy Markdown
Contributor

@spacebear21

spacebear21 commented Jul 22, 2025

Copy link
Copy Markdown
Collaborator

Two tables feels like static type separation Session type as string column feels like dynamic typing, opens up the possibility for invalid data to be representable

This is still an issue with the current approach, except the dynamic type is now the Events instead of the Sessions (Event needs to know whether the session ID is from the receiver or sender table). Or were you implying we use two mutually exclusive, nullable foreign key columns in the Events table, i.e. receiver_session and sender_session? Or even separate event tables?

@nothingmuch

Copy link
Copy Markdown
Contributor

Two tables feels like static type separation Session type as string column feels like dynamic typing, opens up the possibility for invalid data to be representable

This is still an issue with the current approach, except the dynamic type is now the Events instead of the Sessions (Event needs to know whether the session ID is from the receiver or sender table). Or were you implying we use two mutually exclusive, nullable foreign key columns in the Events table, i.e. receiver_session and sender_session? Or even separate event tables?

Yes, I haven't caught up reviewing this yet since I posted that feedback, sounds like it's one events table with multiple session tables in the current state? Yes, I meant to imply by that comment that each session table should have its own events table, too

@Mshehu5
Mshehu5 force-pushed the rusqlite_migration branch 3 times, most recently from cbb0e6d to fc7098e Compare July 23, 2025 14:15

@nothingmuch nothingmuch left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for my unclear suggestion in the last round, the session_type columns should be eliminated entirely, otherwise session_events.session_id can't really be a foreign key

Comment thread payjoin-cli/src/db/mod.rs Outdated
Comment thread payjoin-cli/src/db/mod.rs
Comment thread payjoin-cli/src/db/v2.rs
Comment thread payjoin-cli/src/db/v2.rs Outdated

@arminsabouri arminsabouri left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is in really good shape. I just had a couple nits and questions

Comment thread payjoin-cli/src/db/v2.rs Outdated
Comment thread payjoin-cli/src/db/v2.rs Outdated
Comment thread payjoin-cli/src/db/v2.rs Outdated
Comment thread payjoin-cli/src/db/v2.rs Outdated
Comment on lines +180 to +182
let mut events = Vec::new();
for event_row in event_rows {
let event_data = event_row?;
let event: ReceiverSessionEvent =
serde_json::from_str(&event_data).map_err(Error::Deserialize)?;
events.push(event);
}

Ok(Box::new(events.into_iter()))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there anyway to lazily deserialize this?

Ok(Box::new(event_rows.map(|row| {
    row.and_then(|data| {
        serde_json::from_str::<ReceiverSessionEvent>(&data).map_err(Error::Deserialize)
    })
})))

This might not work bc the return type for this method is not Item=Result<...> in which case is it fine to just use expect() instead map_err(Error::Deserialize)? If we cannot deserialize an event the database is corrupted and I think that is situtation where a panic is appropirate.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made changes to the code and did this :

    `
    let events: Vec<SenderSessionEvent> = event_rows
        .map(|row| {
            let event_data = row.expect("Failed to read event data from database");
            serde_json::from_str::<SenderSessionEvent>(&event_data)
                .expect("Database corruption: failed to deserialize session event")
        })
        .collect();

    Ok(Box::new(events.into_iter()))`

is this implementation oKay?

Comment thread payjoin-cli/src/db/mod.rs Outdated
@Mshehu5
Mshehu5 force-pushed the rusqlite_migration branch 3 times, most recently from 7bb1750 to 77c70ec Compare August 16, 2025 23:54
@Mshehu5
Mshehu5 requested a review from arminsabouri August 17, 2025 16:27

@arminsabouri arminsabouri left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Mshehu5 e2e tests seem to be passing but my manual smoke testing is failing

Got a request from the sender. Responding with a Payjoin proposal.
Fallback transaction received. Consider broadcasting this to get paid if the Payjoin fails:
020000000001015ef1cd09f4429aa5474d29f2bd5eb24beec945ee06f3619cd75d35019d5324090000000000fdffffff02b3bd402500000000160014146eaf160b122f82249868f48f25dccf5c53d0a2a08601000000000016001404281346f0fde7c11261b9311c42ade13ec318dc0247304402205c59ad8f13cd31803e18e0151e15d589d9ee8ec6c4038503ed4c1c24df5535c402203040fb1a876c496a28194546a8f4646504896cf57858b5949fd5e12d55fd886e0121034d97fb9811f63b1f194c67bbedd85dde211289eb85539d8c72d08b40183de10e00000000
[2025-08-19T15:49:12Z DEBUG reqwest::connect] starting new connection: https://pj.bobspacebkk.com/
[2025-08-19T15:49:12Z DEBUG rustls::client::hs] No cached session for DnsName("pj.bobspacebkk.com")
[2025-08-19T15:49:12Z DEBUG rustls::client::hs] Not resuming any session
[2025-08-19T15:49:12Z DEBUG rustls::client::hs] ALPN protocol is None
[2025-08-19T15:49:12Z DEBUG rustls::client::hs] Using ciphersuite TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
[2025-08-19T15:49:12Z DEBUG rustls::client::tls12::server_hello] Server supports tickets
[2025-08-19T15:49:12Z DEBUG rustls::client::tls12] ECDHE curve is EcParameters { curve_type: NamedCurve, named_group: X25519 }
[2025-08-19T15:49:12Z DEBUG rustls::client::tls12] Server DNS name is DnsName("pj.bobspacebkk.com")
[2025-08-19T15:49:13Z DEBUG rustls::common_state] Sending warning alert CloseNotify
Error: Replied with error: {"errorCode":"original-psbt-rejected","message":"The receiver rejected the original PSBT."}

Are you able to re-create this? Just sending and receiving via payjoin-cli.

Comment thread payjoin-cli/src/db/mod.rs Outdated
pub(crate) const DB_PATH: &str = "payjoin.sqlite";

pub(crate) struct Database(sled::Db);
pub(crate) struct Database {

@arminsabouri arminsabouri Aug 19, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit:

Suggested change
pub(crate) struct Database {
struct Database(Pool<SqliteConnectionManager>)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it seems the struct needs to be accessible within the crate so pub(crate) needs to be available but i have changed it to a tuple struct as you suggested

@arminsabouri

Copy link
Copy Markdown
Contributor

Also heads up there are some conflicts

@Mshehu5

Mshehu5 commented Aug 19, 2025

Copy link
Copy Markdown
Contributor Author

@Mshehu5 e2e tests seem to be passing but my manual smoke testing is failing

Got a request from the sender. Responding with a Payjoin proposal.
Fallback transaction received. Consider broadcasting this to get paid if the Payjoin fails:
020000000001015ef1cd09f4429aa5474d29f2bd5eb24beec945ee06f3619cd75d35019d5324090000000000fdffffff02b3bd402500000000160014146eaf160b122f82249868f48f25dccf5c53d0a2a08601000000000016001404281346f0fde7c11261b9311c42ade13ec318dc0247304402205c59ad8f13cd31803e18e0151e15d589d9ee8ec6c4038503ed4c1c24df5535c402203040fb1a876c496a28194546a8f4646504896cf57858b5949fd5e12d55fd886e0121034d97fb9811f63b1f194c67bbedd85dde211289eb85539d8c72d08b40183de10e00000000
[2025-08-19T15:49:12Z DEBUG reqwest::connect] starting new connection: https://pj.bobspacebkk.com/
[2025-08-19T15:49:12Z DEBUG rustls::client::hs] No cached session for DnsName("pj.bobspacebkk.com")
[2025-08-19T15:49:12Z DEBUG rustls::client::hs] Not resuming any session
[2025-08-19T15:49:12Z DEBUG rustls::client::hs] ALPN protocol is None
[2025-08-19T15:49:12Z DEBUG rustls::client::hs] Using ciphersuite TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
[2025-08-19T15:49:12Z DEBUG rustls::client::tls12::server_hello] Server supports tickets
[2025-08-19T15:49:12Z DEBUG rustls::client::tls12] ECDHE curve is EcParameters { curve_type: NamedCurve, named_group: X25519 }
[2025-08-19T15:49:12Z DEBUG rustls::client::tls12] Server DNS name is DnsName("pj.bobspacebkk.com")
[2025-08-19T15:49:13Z DEBUG rustls::common_state] Sending warning alert CloseNotify
Error: Replied with error: {"errorCode":"original-psbt-rejected","message":"The receiver rejected the original PSBT."}

Are you able to re-create this? Just sending and receiving via payjoin-cli.

@arminsabouri
I tested it before the PR and also tested it now using RUST_LOG=debug cargo run, but I didn’t run into this. The only problem I experienced was when performing the sender command it took a while to reflect on the receiver’s side. I believe that’s due to my current poor internet connection.

Are there any conditions I need to recreate apart from sending and receiving?

@Mshehu5
Mshehu5 force-pushed the rusqlite_migration branch from 77c70ec to 39f1144 Compare August 19, 2025 20:08
@Mshehu5
Mshehu5 force-pushed the rusqlite_migration branch 4 times, most recently from 4eee871 to 01d44a6 Compare August 20, 2025 09:45
@arminsabouri

Copy link
Copy Markdown
Contributor

@Mshehu5 I will give it another try either today or tommorrow

@arminsabouri arminsabouri left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tACK 01d44a6
Tested payjoin-cli locally and double checked the schemas manually. I pushed a commit updating the .gitignore. And there a few follow up items I'd like to see addressed. Lastly, there is a 1-2 conflicts @Mshehu5 can you please rebase your branch and resolve those conflicts and I will drop a re-ack. Thanks

Comment thread payjoin-cli/src/db/v2.rs
let session_id = session_row?;
session_ids.push(session_id);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: noticing new lines added here and there. Please becareful to keep formatting changes outside of non formatting commits.

Comment thread payjoin-cli/src/db/v2.rs
match id {
SessionId::Send(_) => Ok(Self { db, session_id: id }),
SessionId::Receive(_) =>
panic!("Attempted to create SenderPersister with Receive session ID"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something about this does not seem right to me. Perhaps we should have had stronger types for receiver and sender ids. And the sum type SessionId just wraps those. This can be left as a follow up.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed, this seems like a holdover from when there was only one sessions table and a session type column

Comment thread payjoin-cli/src/db/v2.rs
})
.collect();

Ok(Box::new(events.into_iter()))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another follow up item. I'd like to see the deserialization be done lazily.

Mshehu5 and others added 2 commits August 27, 2025 08:45
This commit replaces the sled-based embedded database with SQLite, addressing the need for more robust, SQL-compatible persistence across payjoin-cli. This aligns with common practices in other projects (e.g., Liana, BullBitcoin) and makes payjoin-cli a better reference for downstream wallet developers.

Key changes:

Replace sled with rusqlite (v0.29.0) and r2d2_sqlite (v0.22.0) for pooled connection support
Introduce a relational schema with three tables:
sessions: tracks session_id and completed_at
session_events: logs events, sorted by created_at, FK to sessions
inputs_seen: stores seen inputs with timestamps
Redesign SessionId as a fixed-length 8-byte identifier using a deterministic hash from system time and thread ID
Improve error handling by simplifying enum variants and supporting JSON deserialization errors via From<serde_json::Error>
Update config defaults and documentation: payjoin.sled → payjoin.sqlite
Remove obsolete sled-based files and dependencies
This migration preserves interface compatibility where possible and makes only the necessary changes to ensure smooth integration.
And remove sled dirs as they have now been replaced.

@arminsabouri arminsabouri left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rebased against master
Re-ack

@nothingmuch nothingmuch left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

utACK, concurring with Armin's review feedback

Comment thread payjoin-cli/src/db/v2.rs
Comment on lines +204 to +205
let session_id: i64 = row.get(0)?;
Ok(SessionId::Receive(session_id))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: i think this can be simplified, although I'm confused about why i64 needs to be annoted explicitly in the line i suggest removing, i expected the type constructor to suffice for inference

Suggested change
let session_id: i64 = row.get(0)?;
Ok(SessionId::Receive(session_id))
row.get(0).map(SessionId::Receive)

Comment thread payjoin-cli/src/db/v2.rs
match id {
SessionId::Send(_) => Ok(Self { db, session_id: id }),
SessionId::Receive(_) =>
panic!("Attempted to create SenderPersister with Receive session ID"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed, this seems like a holdover from when there was only one sessions table and a session type column

Comment thread payjoin-cli/src/db/v2.rs
Comment on lines 227 to 233
let mut session_ids = Vec::new();
for item in send_tree.iter() {
let (key, value) = item?;
let wrapper: SessionWrapper<SenderSessionEvent> =
serde_json::from_slice(&value).map_err(Error::Deserialize)?;
if wrapper.completed_at.is_some() {
continue;
}
session_ids.push(SessionId::new(u64::from_be_bytes(
key.as_ref().try_into().map_err(Error::TryFromSlice)?,
)));
for session_row in session_rows {
let session_id = session_row?;
session_ids.push(session_id);
}

Ok(session_ids)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i believe this can just be a call to collect, collecting an Iter<Result<_>> into a Result<Vec<_>> is supported: https://doc.rust-lang.org/std/iter/trait.Iterator.html#method.collect

Suggested change
let mut session_ids = Vec::new();
for item in send_tree.iter() {
let (key, value) = item?;
let wrapper: SessionWrapper<SenderSessionEvent> =
serde_json::from_slice(&value).map_err(Error::Deserialize)?;
if wrapper.completed_at.is_some() {
continue;
}
session_ids.push(SessionId::new(u64::from_be_bytes(
key.as_ref().try_into().map_err(Error::TryFromSlice)?,
)));
for session_row in session_rows {
let session_id = session_row?;
session_ids.push(session_id);
}
Ok(session_ids)
session_rows.collect()

@arminsabouri
arminsabouri merged commit 554c4e6 into payjoin:master Aug 27, 2025
10 checks passed
@arminsabouri arminsabouri mentioned this pull request Aug 27, 2025
2 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Migrate payjoin-cli Sled db to sqlite3

6 participants