Skip to content

Guard concurrent sends with exclusive DB lock and URI/RK checks#1376

Open
Mshehu5 wants to merge 1 commit intopayjoin:masterfrom
Mshehu5:guard_send
Open

Guard concurrent sends with exclusive DB lock and URI/RK checks#1376
Mshehu5 wants to merge 1 commit intopayjoin:masterfrom
Mshehu5:guard_send

Conversation

@Mshehu5
Copy link
Copy Markdown
Contributor

@Mshehu5 Mshehu5 commented Mar 2, 2026

This PR Closes #1032

As discussed in the issue two concurrent send commands targeting the same URI could select different coins. PRAGMA locking_mode = EXCLUSIVE has been set as stated to make only one process holds write access at a time.

And also check to make sure URI and recceiver pubkey which are in use for another session is not reused

Co-authored by claude sonnet 4.6

Pull Request Checklist

Please confirm the following before requesting review:

@coveralls
Copy link
Copy Markdown
Collaborator

coveralls commented Mar 2, 2026

Pull Request Test Coverage Report for Build 23953519233

Details

  • 65 of 71 (91.55%) changed or added relevant lines in 4 files are covered.
  • No unchanged relevant lines lost coverage.
  • Overall coverage increased (+0.04%) to 84.381%

Changes Missing Coverage Covered Lines Changed/Added Lines %
payjoin-cli/src/app/v2/mod.rs 0 1 0.0%
payjoin-cli/src/db/v2.rs 62 63 98.41%
payjoin-cli/src/db/error.rs 0 4 0.0%
Totals Coverage Status
Change from base Build 23927050434: 0.04%
Covered Lines: 10859
Relevant Lines: 12869

💛 - Coveralls

@Mshehu5 Mshehu5 marked this pull request as ready for review March 3, 2026 13:57
Copy link
Copy Markdown
Collaborator

@zealsham zealsham left a comment

Choose a reason for hiding this comment

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

tAck!
Can you rebase as this branch is almost a month old.

Comment on lines +53 to +56
// Create a new session in send_sessions and get its ID
let session_id: i64 = conn.query_row(
"INSERT INTO send_sessions (session_id, receiver_pubkey) VALUES (NULL, ?1) RETURNING session_id",
params![receiver_pubkey.to_compressed_bytes()],
"INSERT INTO send_sessions (pj_uri, receiver_pubkey) VALUES (?1, ?2) RETURNING session_id",
params![pj_uri, &receiver_pubkey_bytes.as_slice()],
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I don't think .as_slice() does anything here as the receiver_pubkey_bytes is already [u8]

@Mshehu5 Mshehu5 force-pushed the guard_send branch 2 times, most recently from 39a1c67 to cc8e38d Compare March 24, 2026 13:15
Copy link
Copy Markdown
Collaborator

@zealsham zealsham left a comment

Choose a reason for hiding this comment

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

ACK! , it still needs another pair of eyes before we can get it in

tested and got this

✦ ❯ ./../../target/debug/payjoin-cli send  "bitcoin:bcrt1qdlfxfpmyn08g6wytw0qqq623mn7463ezplreaq?amount=0.00001&pjos=0&pj=HTTPS://PAYJO.IN/FZRC3CRDXV826%23EX1VEHUG6G-OH1QYPFLM8XL59R0XV4VGPLS7FRDSSM4TUXL07TXCWC4S0GLVLNK2SE4NQ-RK1QV6EYM8CPS9DL5CZKYGC2WWNLNL8D00PCPZ3M83K70ATLGC2GAD6Q " --fee-rate=1 &  ./../../target/debug/payjoin-cli send  "bitcoin:bcrt1qdlfxfpmyn08g6wytw0qqq623mn7463ezplreaq?amount=0.00001&pjos=0&pj=HTTPS://PAYJO.IN/FZRC3CRDXV826%23EX1VEHUG6G-OH1QYPFLM8XL59R0XV4VGPLS7FRDSSM4TUXL07TXCWC4S0GLVLNK2SE4NQ-RK1QV6EYM8CPS9DL5CZKYGC2WWNLNL8D00PCPZ3M83K70ATLGC2GAD6Q " --fee-rate=1
[1] 62106
Error: Database operation failed: database is locked

Caused by:
    0: database is locked
    1: Error code 5: The database file is locked

rust-payjoin/payjoin-cli/sender on  guard_send [?] took 5s 
✦ ❯ Bootstrapping private network transport over Oblivious HTTP
Posted original proposal...
Proposal received. Processing...
Payjoin sent. TXID: af8dee914cbb9608723e49a88be8a5e70e0f62a442db9d6b79c53740bd56172a

[1]  + 62106 done       ./../../target/debug/payjoin-cli send  --fee-rate=1

with only one of the transaction going through which is intended as per implementation

Copy link
Copy Markdown
Contributor

@nothingmuch nothingmuch left a comment

Choose a reason for hiding this comment

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

i think completed sessions should still not allow reuse

Specific carveouts for canceled or failed sessions can be added, but we should still do that carefully, for example if two non-double-spending fallback transactions are given, that can result in the sender overpaying. this PR currently makes doing that unintentionally more difficult, but not impossible, IMO it should be impossible

pub(crate) fn create(path: impl AsRef<Path>) -> Result<Self> {
let manager = SqliteConnectionManager::file(path.as_ref());
let manager = SqliteConnectionManager::file(path.as_ref())
.with_init(|conn| conn.execute_batch("PRAGMA locking_mode = EXCLUSIVE;"));
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.

isn't it more appropriate to put this in init_schema? or was this done deliberately to upgrade existing databases? if so some explanatory comment should be given

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.

thanks for the clarification, i thought it was a schema level pragma

Comment on lines +40 to +41
EXISTS(SELECT 1 FROM send_sessions WHERE completed_at IS NULL AND pj_uri = ?1), \
EXISTS(SELECT 1 FROM send_sessions WHERE completed_at IS NULL AND receiver_pubkey = ?2)",
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.

what's the rationale for completed_at IS NULL? if a completed session with a duplicate session exists shouldn't this also ensure that it was canceled in order to allow reusing the URI?

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.

true, this has been removed

anyhow = "1.0.99"
async-trait = "0.1.89"
bitcoind-async-client = { git = "https://github.com/benalleng/bitcoind-async-client.git", rev = "a6292420de5cfd666d6185c534bea3f22736452b" }
bitcoind-async-client = { git = "https://github.com/benalleng/bitcoind-async-client.git", rev = "96a4bde287c397981faba74896dd3ceff6bd58e4" }
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 this change intended?

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.

also for this bitcoind-async-client had an issues so we had to use a fork of ben allen at the time so i had to make the change to pass CI this has been fixed in #1441

);
}

/// After a session is marked completed, a new session with the same URI should be allowed.
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 disagree, that may result in address reuse, which is very bad, as well as key reuse of the HPKE receiver key, which is arguably not as bad but still undesirable

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.

changes have been so even after a completed session, a new session with the same URI will still be rejected

Copy link
Copy Markdown
Contributor

@nothingmuch nothingmuch left a comment

Choose a reason for hiding this comment

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

utACK except for minor nits

i'm confused about the weird formatting of that removed line, i would have expected CI to complain about that


pub fn from_id(db: Arc<Database>, id: SessionId) -> Self { Self { db, session_id: 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.

i think this needs rustfmt

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.

seems according to rustfmt rules this is the right way to write it
also tried adding the line but rust fmt removed it

}

#[derive(Clone)]
#[derive(Clone, Debug)]
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 the debug still necessary?

Copy link
Copy Markdown
Contributor Author

@Mshehu5 Mshehu5 Apr 3, 2026

Choose a reason for hiding this comment

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

still needed for test, when removed test error

for session_id in send_session_ids {
let sender_persiter = SenderPersister::from_id(self.db.clone(), session_id.clone());
match replay_sender_event_log(&sender_persiter) {
let sender_persister = SenderPersister::from_id(self.db.clone(), session_id.clone());
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.

in this case it doesn't matter but as i'm lookinmg at this change for the 3rd time and remembering it's just a typo fix that's unrelated, it'd be helpful if in the future this kind of change was in a separate commit

Two concurrent send commands targeting the same URI could select different coins. Set PRAGMA locking_mode = EXCLUSIVE so only one process holds write access at a time.

Check uniqueness of URI and recceiver pubkey with duplicate checks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

payjoin-cli should be more concurrent safe and avoid accidental double playments

4 participants